Craftkitdocs

Overview

Drop the Craftkit builder into your SaaS so your customers design their own templates.

01 — Embed Architecture

Mental model: the partner SaaS owns the data; Craftkit owns the editing experience. They never share schemas. Instead, the partner injects its variable catalog into the embedded builder for the duration of one editing session.

Two iframe surfaces share this architecture: the builder embed (/embed/builder) where partner end-users design templates, and the form-fill embed (/embed/form) where they fill a published template and produce documents. Same JWT mint flow, same origin pinning, same appearance pipeline — only the UI inside the iframe differs. See 12-form-route.md for the form embed's full design.

The big picture

┌─────────────────────────────────┐
│  Partner SaaS                   │
│  ┌───────────────────────────┐  │     ┌──────────────────────────┐
│  │ "Edit document template"  │──┼────▶│  Craftkit Server         │
│  │  (button in their UI)     │  │     │  POST /v1/embed/sessions │
│  └───────────────────────────┘  │◀────│  → JWT (5 min TTL)       │
│             │                   │     └──────────────────────────┘
│             ▼                   │
│  ┌───────────────────────────┐  │
│  │ <iframe src="embed.       │  │     Craftkit Embed UI
│  │   craftkit.dev/builder    │──┼────▶ (catalog injected from JWT,
│  │   ?session_token=JWT" />  │  │      branded to host)
│  └───────────────────────────┘  │
│             ▲                   │
│             │ postMessage events│     ┌──────────────────────────┐
│             │ (template.saved,  │────▶│  Craftkit webhook        │
│             │  template.published)│   │  → partner SaaS backend  │
└─────────────────────────────────┘     └──────────────────────────┘

Two communication channels:

  • window.postMessage — client-to-client, low-latency UI
  • Webhooks — Craftkit → partner backend, durable record-of-truth

This dual channel is exactly how Stripe Elements, Plaid Link, and Zapier Embed work.

Three credential tiers

Credential Lives where Used for
Secret API key (ck_live_*) Partner's server only Mint embed sessions, run renders
Publishable embed key (ck_pk_*) Can be in browser Identifies the partner integration; not enough alone
Embed session token (signed JWT, 5 min TTL) The iframe URL Authenticates one user editing one template once

Cookies are dead in third-party iframes (Safari/Chrome block them). The model is stateless JWT-in-URL with postMessage refresh — no cookies, no CORS pain, no long-lived browser credentials.

The embed session lifecycle

1. Partner backend POSTs /v1/embed/sessions with secret + claims payload
2. Craftkit:
   - Validates partner status, origin, project
   - Stores variable_catalog (if inline) under cat_… reference
   - Upserts tenant + actor by external_ids
   - Mints Ed25519-signed JWT with claims
   - Returns { session_token, iframe_url, expires_at, renew_token }
3. Partner mounts iframe with session_token in URL
4. iframe page server-validates JWT (signature, exp, aud, iss, origin)
5. iframe fetches catalog by ref using session_token
6. Tiptap editor hydrates with catalog injected
7. User edits → builder posts events to parent via postMessage
8. ~30s before exp: iframe → parent → partner backend → /v1/embed/sessions/refresh
9. New JWT replaces in-memory token; URL never changes
10. On publish: webhook fires to partner; postMessage to parent for snappy UX

Why catalog_ref is split from inline catalog

Variable catalogs can be large (typical partner has 100–500 fields). Encoding all of that in a JWT URL would explode past browser URL limits.

Solution: partner POSTs the catalog once when minting the session; Craftkit stores it server-side under cat_…; the JWT carries only the reference. The embed page fetches it with Authorization: Bearer <session_token> on load.

Server-side validation rules

Every embed page request must pass ALL of these checks before rendering:

  1. JWT signature valid against current/previous Ed25519 keys
  2. exp > now, nbf ≤ now, iat ≤ now
  3. aud matches the request's Host header
  4. iss resolves to a partner whose status is active
  5. The request's Origin (or Referer) is in the partner's allowed-origins list
  6. ck.partner.project_id belongs to the partner
  7. ck.scope.template_id (if present) belongs to the partner's project AND the template is not deleted
  8. ck.catalog_ref is owned by this session (not a different session's catalog)
  9. jti not yet seen → mark seen for exp window (prevents URL replay)

If any fail → render an opaque error page (/embed/error?code=session_invalid), never leak which check failed.

Renewal flow

T-30s before expiry:
  iframe → parent : { type: 'craftkit.session.expiring', seconds: 30 }
  parent → its backend : POST /api/craftkit/refresh-session { renew_token }
  backend → Craftkit  : POST /v1/embed/sessions/refresh
                        { renew_token, ck_live_* }
                    ←   { session_token: "eyJ…", expires_at: ... }
  parent → iframe   : { type: 'craftkit.token.refresh', token: 'eyJ…' }
  iframe acknowledges and replaces in-memory token

renew_token is single-use (rotates each refresh). Limits replay attacks if a JWT leaks.

Data flow: who knows what

Critical privacy property:

Data Partner backend Partner frontend Craftkit Iframe
Customer's PII ✅ (their data) ✅ (their data)
Variable catalog (field names + types) ✅ (stored server-side) ✅ (read via session)
Template content (Tiptap JSON, manifest) ✅ (current session)
Render input data ✅ (when calling /v1/render) ✅ (transient, in renders row)
Generated PDFs ✅ (via webhook) ✅ (in S3)

Craftkit never sees customer PII. It only sees:

  • The shape of the partner's data (via catalog)
  • The text the user types in templates
  • Render-time input data, which the partner controls

Rendering: Push vs Pull modes

Once a template is published, the partner needs to feed real data into renders.

Push mode (default, 95% of cases)

Partner generates a charter contract for booking #12345
  ↓
Partner fetches its own data, shapes it into the manifest's keys
  ↓
Partner calls POST https://api.craftkit.dev/v1/templates/charter-contract/render
  with bearer ck_live_* and { data: { ... } }
  ↓
Craftkit renders → fires webhook → partner stores PDF URL

Pull mode (advanced, optional)

The partner registers a data resolver URL when minting the embed session:

"resolver_url": "https://saas.com/api/craftkit/resolve",
"resolver_secret": "shared-hmac-secret"

Then end users can trigger renders directly from Craftkit's dashboard with just a record ID:

User clicks "Render for booking #12345" inside Craftkit dashboard
  ↓
Craftkit POSTs to https://saas.com/api/craftkit/resolve
  with { template_id, record_id: "12345" }
  ↓
Partner responds with { data: {...} }
  ↓
Craftkit renders → returns PDF URL to caller

Most integrations start with Push and add Pull later.

Form embed (sibling iframe surface)

Alongside the builder, Craftkit ships a form-fill embed at /embed/form?session_token=…. It is a runtime sibling of the builder, not a replacement:

Builder embed Form embed
Path /embed/builder /embed/form
Audience Template designers End-users filling a template
JWT scope mode 'edit' | 'create' | 'view' 'fill'
Output Saved/published template version A render row + an in-iframe PDF

Reused unchanged: the POST /v1/embed/sessions mint flow, Ed25519 JWT signing + renewal, the postMessage bus, origin pinning, the appearance pipeline (normalizeAppearancecraftkit.appearance.set), and the @craftkit/embed SDK transport. Form sessions add two endpoints (POST /v1/embed/form-submit/:sessionId, GET /v1/embed/renders/:id), new postMessage event types, and Craftkit.mountForm() in the SDK.

Renders produced by the form embed land in the same render table as programmatic /v1/templates/:slug/render calls; a new render.source column distinguishes 'api' | 'form' | 'partner_supplied' | 'dashboard' so the partner's renders list, billing, and webhook delivery all work uniformly. Full design in 12-form-route.md.

Deployment topologies

Tier Topology Domain Effort Use case
Standard embed Hosted iframe at embed.craftkit.dev Craftkit's Zero infra Most partners
Branded subdomain CNAME builder.partner.com → Craftkit edge Partner's DNS + cert Mid-market white-label
Reverse-proxy embed Partner proxies /builder/* to Craftkit edge Partner's Partner-side proxy config Strict-CSP partners
Dedicated instance Single-tenant Craftkit deployment, partner-pinned domain Partner's Provisioning automation Enterprise/regulated
Self-hosted Partner runs the whole stack Partner's Helm chart + license Banks, healthcare

The Craftkit codebase doesn't change between standard and branded — same app reading host header → tenant config → branding tokens.


Last revised: 2026-05-02