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
publishandsaveDraft— these are the core actions. - Disable
deleteandrenameunless you sync those events back to your DB. - Disable
createCustomVariablesto 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)
Using the SDK (recommended)
npm i @craftkit/embedimport { 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:
- Iframe emits
craftkit.session.expiring(~30s before exp) - SDK calls your
refresh()function - Your frontend hits your backend:
POST /api/craftkit/refresh - Your backend calls
POST https://api.craftkit.dev/v1/embed/sessions/refresh - Craftkit returns a new
session_token(and rotates therenew_token) - 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_tokenis 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 DBKey rules:
templateIdis only available after the firsttemplate.savedortemplate.publishedevent (formode=create,templateIdisnullin thereadyevent).- Drafts never trigger a webhook — only publishes do.
- The
manifestin 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
});Related
- Embed quickstart — builder + form setup in five minutes
- Form-fill embeddable — let end-users fill published templates
- Variable catalog — inject your data model into the picker
- Styling & themes — brand the builder for your customers
- JWT spec — session token shape and signing
- postMessage protocol — raw event bus reference
- Host SDK — full SDK API surface
- POST /v1/embed/catalogs — publish catalogs from CI/CD
- Authentication — API key setup and troubleshooting