Craftkitdocs

Builder embed

Embed the template designer in your SaaS — scope modes, permissions, events, imperative commands, and the template creation lifecycle.

Full reference for embedding the Craftkit template builder inside your SaaS so your customers can design, edit, and publish their own document templates — without leaving your product.

Before you start: Enable embed mode for your project (Dashboard → Project → Embed → Overview → Enable embed mode) and make sure you have an API key minted in the target environment. See Embed Quickstart for both prerequisites.

Quick Start

Mint a session (server-side)

curl -X POST https://api.craftkit.dev/v1/embed/sessions \
  -H "Authorization: Bearer $CK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant": { "externalId": "org_42",    "displayName": "Acme Corp" },
    "actor":  { "externalId": "user_99",   "email": "ops@acme.com" },
    "scope":  { "mode": "edit", "templateExternalId": "charter-contract" }
  }'

Returns:

{
  "session_id": "sess_01...",
  "session_token": "eyJ...",
  "iframe_url": "https://embed.craftkit.dev/embed/builder?session_token=eyJ...",
  "expires_at": "2026-05-03T10:30:00.000Z",
  "renew_token": "ert_..."
}

Mount the builder (client-side)

import { Craftkit } from '@craftkit/embed';

const ck = Craftkit.init({ publishableKey: 'ck_pk_live_...' });

const builder = ck.mountBuilder({
  container: '#builder',
  sessionToken: sessionToken,
  autoResize: true,
  refresh: async () => {
    const r = await fetch('/api/craftkit/refresh', { method: 'POST' });
    return (await r.json()).session_token;
  },
});

builder.on('template.published', ({ templateId, version, manifest }) => {
  closeModal();
  showToast(`Template published (v${version})`);
});

builder.on('close.requested', () => builder.destroy());

Scope modes

The scope.mode field on the session controls which surface loads and what the user can do.

Mode Iframe loads When to use
edit Builder with existing template Customer edits a template they already created
create Builder with blank canvas Customer starts a brand-new template
view Builder in read-only Preview without editing (e.g. approval flows)

For edit and view, supply either scope.templateExternalId (your stable identifier) or scope.templateId (Craftkit's internal UUID). For create, omit both — the builder creates a new template and returns the templateId in template.published.

// Edit an existing template
{ "scope": { "mode": "edit", "templateExternalId": "charter-contract" } }

// Create a new template from scratch
{ "scope": { "mode": "create" } }

// Read-only preview
{ "scope": { "mode": "view", "templateExternalId": "charter-contract" } }

Step 1 — Mint a session (server-side)

Never mint sessions in the browser — the secret API key must stay on your server.

Full request shape

POST https://api.craftkit.dev/v1/embed/sessions
Authorization: Bearer ck_live_...
Content-Type: application/json

{
  "tenant": {
    "externalId": "org_42",           // your stable org/account ID
    "displayName": "Acme Corp"        // shown in the Craftkit admin view
  },
  "actor": {
    "externalId": "user_99",          // your stable user ID within the tenant
    "email": "ops@acme.com",          // optional — audit trail only
    "displayName": "Ops Team"         // optional
  },
  "scope": {
    "mode": "edit",                   // "edit" | "create" | "view"
    "templateExternalId": "charter-contract"  // omit for mode=create
  },
  "permissions": {
    "publish":              true,
    "saveDraft":            true,
    "delete":               false,
    "rename":               false,
    "rollback":             false,
    "createCustomVariables": false,
    "changePageSettings":   true,
    "viewVersionHistory":   true
  },
  "branding": {
    "logoUrl": "https://acme.com/logo.svg",
    "primaryColor": "#2563EB",
    "locale": "en"
  },
  "callbacks": {
    "onPublished": "https://saas.acme.com/api/craftkit/events",
    "onCloseUrl":  "https://saas.acme.com/templates"
  },
  "limits": {
    "maxPublishes":   10,
    "maxSaveDrafts":  200,
    "maxUploadsBytes": 5242880
  },
  "catalogId": "cat_01..."   // optional — attach a variable catalog
}

Permissions reference

Permission Default Effect when false
publish false Publish button hidden; triggerPublish() returns permission_denied
saveDraft false Save button hidden; auto-save disabled
delete false Delete template option hidden
rename false Template title is read-only
rollback false Version history shown (if viewVersionHistory: true) but rollback disabled
createCustomVariables false Variable picker shows only catalog fields; "+" custom variable hidden
changePageSettings false Page size / margin settings locked
viewVersionHistory false Version history panel hidden entirely

Recommended defaults for a multi-tenant SaaS:

  • Enable publish and saveDraft — these are the core actions.
  • Disable delete and rename unless you sync those events back to your DB.
  • Disable createCustomVariables to keep templates locked to your data model.

Response

{
  "session_id":    "sess_01...",
  "session_token": "eyJ...",              // 5-minute JWT, pass to the iframe
  "iframe_url":    "https://embed.craftkit.dev/embed/builder?session_token=eyJ...",
  "expires_at":    "2026-05-03T10:30:00.000Z",
  "renew_token":   "ert_..."              // keep server-side for refresh
}

Store renew_token server-side. Return only session_token (and optionally iframe_url) to your frontend.

Node.js helper

async function mintBuilderSession({ orgId, userId, userEmail, templateExternalId }) {
  const res = await fetch('https://api.craftkit.dev/v1/embed/sessions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CK_SECRET}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      tenant:  { externalId: orgId,    displayName: orgId },
      actor:   { externalId: userId,   email: userEmail },
      scope:   { mode: templateExternalId ? 'edit' : 'create', templateExternalId },
      permissions: { publish: true, saveDraft: true, viewVersionHistory: true },
    }),
  });
  if (!res.ok) {
    const { error } = await res.json();
    throw new Error(`${error.code}: ${error.message}`);
  }
  const { session_token, renew_token, expires_at } = await res.json();
  // store renew_token → your sessions store keyed by userId
  return { sessionToken: session_token, expiresAt: expires_at };
}

Step 2 — Mount the builder (client-side)

npm i @craftkit/embed
import { Craftkit } from '@craftkit/embed';

const ck = Craftkit.init({
  publishableKey: 'ck_pk_live_...',
  debug: false,
});

const builder = ck.mountBuilder({
  container: '#builder-host',   // CSS selector or HTMLElement
  sessionToken,
  autoResize: true,             // resizes iframe to content height
  refresh: async () => {
    // Called automatically ~30s before token expiry
    const r = await fetch('/api/craftkit/refresh', { method: 'POST' });
    return (await r.json()).session_token;
  },
});

Using a plain iframe

For frameworks that render iframes natively:

<!-- Vanilla HTML -->
<iframe src="https://embed.craftkit.dev/embed/builder?session_token=..."
        style="width:100%;height:100%;border:0">
</iframe>
// React
<iframe
  src={iframeUrl}
  style={{ width: '100%', height: '100%', border: 0 }}
/>
<!-- Vue -->
<iframe :src="iframeUrl" style="width:100%;height:100%;border:0" />

When not using the SDK you must handle session refresh and postMessage events manually. See postMessage protocol.


Step 3 — Handle events

Essential events

// Iframe fully loaded and ready
builder.on('ready', ({ sessionId, templateId }) => {
  console.log('Builder ready, session:', sessionId);
});

// User published a version — this is your primary signal
builder.on('template.published', ({ templateId, version, manifest }) => {
  // templateId: Craftkit's UUID for the template
  // version: monotonic integer
  // manifest: { fields: [{ key, type, required }] } — the variable schema
  await saveTemplateToYourDB({ templateId, version });
  closeModal();
  showToast(`Published v${version}`);
});

// User saved a draft (not published)
builder.on('template.saved', ({ templateId, version, manifest }) => {
  setDraftIndicator(`Draft v${version} saved`);
});

// User clicked the close button
builder.on('close.requested', () => {
  builder.destroy();
  closeModal();
});

// Any error surface
builder.on('error', (err) => {
  console.error('Craftkit error:', err.code, err.message);
  if (!err.recoverable) showFallbackUI();
});

Session events

builder.on('session.expiring', ({ secondsRemaining }) => {
  // The SDK calls refresh() automatically if you provided it.
  // This event is for your own UI feedback if needed.
  console.log(`Session expiring in ${secondsRemaining}s`);
});

builder.on('session.refreshed', ({ newExpiresAt }) => {
  console.log('Session renewed until', newExpiresAt);
});

builder.on('session.expired', () => {
  // Only fires if refresh failed or wasn't provided.
  builder.destroy();
  showError('Session expired. Please reopen the editor.');
});

Variable events

builder.on('variable.inserted', ({ key, type, source }) => {
  // source: 'catalog' | 'custom'
  updateVariableList(key, type);
});

builder.on('variable.removed', ({ key }) => {
  removeFromVariableList(key);
});

Full event reference

Event Payload Notes
ready { sessionId, templateId } templateId is null for mode=create until first save
template.saved { templateId, version, manifest } Draft — not yet published
template.published { templateId, version, manifest } Triggers your webhook too
variable.inserted { key, type, source } After a variable node is dropped in
variable.removed { key } After removal
session.expiring { secondsRemaining } ~30s before JWT exp
session.refreshed { newExpiresAt } After token replacement
session.expired {} Refresh failed or was not provided
height.changed { heightPx } For manual iframe sizing when autoResize: false
close.requested {} User clicked close — you control what happens
error { code, message, recoverable } See error codes below

Error codes:

Code Recoverable Cause
session_invalid No JWT failed validation (wrong env, expired at load, embed not enabled)
session_expired No TTL elapsed and no refresh was provided
origin_not_allowed No Parent origin not in the allowed-origins list
permission_denied Yes Action attempted without the required permission
iframe_load_failed Yes Network error loading the iframe
refresh_failed No refresh() threw or returned an invalid token
rate_limited Yes Too many requests
unknown Maybe Unexpected server error

Imperative commands

You can drive the builder programmatically in addition to listening for events.

// Programmatically save the current state
const { version } = await builder.triggerSave();
console.log('Saved as draft v' + version);

// Programmatically publish
const { version } = await builder.triggerPublish();
console.log('Published v' + version);

// Switch to a different template mid-session (same session token)
await builder.loadTemplate('other-template-external-id');

// Inject preview data so variables resolve in the live preview
builder.setPreviewData({
  'customer.name': 'Ada Lovelace',
  'booking.date': '2026-06-15',
});

// Update theme without remounting
builder.setTheme({ primaryColor: '#FF6600', logoUrl: 'https://...' });

// Focus the editor (e.g. after user clicks a surrounding UI element)
builder.focus();

// Tear down and clean up all listeners
builder.destroy();

Session refresh

Sessions expire after 5 minutes. The SDK handles renewal automatically if you supply a refresh callback. The renewal flow:

  1. Iframe emits craftkit.session.expiring (~30s before exp)
  2. SDK calls your refresh() function
  3. Your frontend hits your backend: POST /api/craftkit/refresh
  4. Your backend calls POST https://api.craftkit.dev/v1/embed/sessions/refresh
  5. Craftkit returns a new session_token (and rotates the renew_token)
  6. SDK sends the new token to the iframe via postMessage

Backend refresh endpoint:

app.post('/api/craftkit/refresh', async (req, res) => {
  const renewToken = getRenewTokenForUser(req.user.id);  // from your DB
  const r = await fetch('https://api.craftkit.dev/v1/embed/sessions/refresh', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CK_SECRET}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ renewToken }),
  });
  const { session_token, renew_token } = await r.json();
  updateRenewTokenForUser(req.user.id, renew_token);  // rotate in your DB
  res.json({ session_token });
});

renew_token is single-use. Rotate it on every successful refresh or it becomes invalid.


The template creation lifecycle

Understanding this lifecycle is important for syncing Craftkit's state with your own database.

mode=create                              mode=edit
     │                                       │
     ▼                                       ▼
[Blank canvas]                    [Existing template loaded]
     │                                       │
     │   User designs the template           │   User edits
     ▼                                       ▼
[template.saved]  ←─────────────────── [template.saved]
     │  version increments (draft)           │
     ▼                                       ▼
[template.published] ──────────────── [template.published]
     │  version increments                   │
     │  webhook fires                        │  webhook fires
     ▼                                       ▼
  Your DB ◄──── store templateId + version ──── Your DB

Key rules:

  • templateId is only available after the first template.saved or template.published event (for mode=create, templateId is null in the ready event).
  • Drafts never trigger a webhook — only publishes do.
  • The manifest in the event payload contains the live variable schema. Use it to validate downstream render calls.
  • Version numbers are monotonic integers. A new publish always increments.

Connecting template.published to your own data

builder.on('template.published', async ({ templateId, version, manifest }) => {
  // 1. Store the mapping in your database
  await db.upsert('craftkit_templates', {
    externalId: currentTemplateExternalId,
    craftkitTemplateId: templateId,
    currentVersion: version,
    variables: manifest.fields,
  });

  // 2. Use the manifest to pre-validate render data
  const requiredKeys = manifest.fields.filter(f => f.required).map(f => f.key);
  console.log('Required fields for render:', requiredKeys);

  // 3. Close the editor
  builder.destroy();
  router.push(`/templates/${currentTemplateExternalId}`);
});

Branding the builder

Branding can be set at session mint time (static), sent at runtime via builder.setTheme(), or updated via the dashboard's Themes page.

At session mint

{
  "branding": {
    "logoUrl":      "https://yourapp.com/logo.svg",
    "primaryColor": "#2563EB",
    "locale":       "en",
    "ui": {
      "showTopBar":          false,
      "showTemplateList":    false,
      "showRenderHistory":   false,
      "showApiKeysLink":     false,
      "showPublishButton":   true,
      "showSaveDraftButton": true,
      "showCloseButton":     true
    }
  }
}

At runtime

builder.setTheme({
  primaryColor: '#FF6600',
  logoUrl: 'https://yourapp.com/logo-dark.svg',
});

For advanced theming (CSS custom properties, font injection, density, layout), see Styling & themes.


Attaching a variable catalog

A variable catalog injects your data model into the template builder so users see your fields (customer name, booking date, etc.) in the variable picker.

Publish the catalog first (once, from CI/CD):

curl -X POST https://api.craftkit.dev/v1/embed/catalogs \
  -H "Authorization: Bearer $CK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acme-default",
    "catalog": {
      "allowCustom": false,
      "namespaces": [{
        "key": "customer", "label": "Customer",
        "fields": [
          { "key": "customer.name",  "label": "Name",  "dataType": "text" },
          { "key": "customer.email", "label": "Email", "dataType": "email" }
        ]
      }],
      "loops": []
    }
  }'
# → { "id": "cat_01...", "name": "acme-default", "version": 1 }

Reference in the session:

{
  "catalogId": "cat_01...",
  "scope": { "mode": "edit", "templateExternalId": "charter-contract" }
}

See Variable catalog for the full field schema and POST /v1/embed/catalogs for the API reference.


Webhooks

postMessage events (.published, .saved) are low-latency but not durable — they're lost if the tab closes. Webhooks are the reliable, server-to-server source of truth.

Configure the webhook URL in callbacks.onPublished at session mint or in the dashboard.

Payload:

{
  "type": "template.published",
  "templateId": "ck_tpl_01...",
  "version": 3,
  "manifest": { "fields": [{ "key": "customer.name", "type": "text", "required": true }] },
  "tenantExternalId": "org_42",
  "actorExternalId": "user_99",
  "timestamp": "2026-05-03T10:28:00.000Z"
}

Verifying the signature:

import { createHmac } from 'crypto';

function verifyWebhook(rawBody, signatureHeader, secret) {
  const expected = createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  return `sha256=${expected}` === signatureHeader;
}

app.post('/api/craftkit/events', (req, res) => {
  const sig = req.headers['x-craftkit-signature'];
  if (!verifyWebhook(req.rawBody, sig, process.env.CK_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'invalid_signature' });
  }
  const event = JSON.parse(req.rawBody);
  if (event.type === 'template.published') {
    await handlePublish(event);
  }
  res.json({ received: true });
});

Error handling

invalid_credentials on session mint: The API key was created in a different environment, or embed is not enabled for the project. See Authentication troubleshooting.

error event with session_invalid: The session token failed server-side validation. Most common causes: embed not enabled, key from wrong environment, or the token expired before the iframe loaded. Mint a fresh session.

error event with origin_not_allowed: The parent page's origin is not in the allowed-origins list. Add it via Dashboard → Project → Embed → Origins.

builder.on('error', (err) => {
  if (!err.recoverable) {
    // Show a fallback UI — the builder cannot continue
    showFallback(`Editor unavailable: ${err.message}`);
    builder.destroy();
  }
  // Recoverable errors (permission_denied, rate_limited) can be surfaced inline
});