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:
- JWT signature valid against current/previous Ed25519 keys
exp > now,nbf ≤ now,iat ≤ nowaudmatches the request'sHostheaderissresolves to a partner whose status isactive- The request's
Origin(orReferer) is in the partner's allowed-origins list ck.partner.project_idbelongs to the partnerck.scope.template_id(if present) belongs to the partner's project AND the template is not deletedck.catalog_refis owned by this session (not a different session's catalog)jtinot yet seen → mark seen forexpwindow (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 tokenrenew_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 URLPull 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 callerMost 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 (normalizeAppearance → craftkit.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