Host SDK
TypeScript helper for mounting the iframe and listening to events.
03 — @craftkit/embed SDK
The drop-in JavaScript SDK that partners use to mount Craftkit's two embed
surfaces: the builder (mountBuilder) for designing templates and the
form-fill embed (mountForm) for end-users to fill a published template
and produce a document. Both share the same Craftkit.init(...) client,
the same origin gating, and the same refresh/teardown plumbing.
Goals:
- Tiny, dependency-free
- ESM + CJS + UMD builds
- Loadable as
<script>or vianpm i @craftkit/embed - Strict origin pinning + auto-cleanup
- Fully typed events
Partner integration (the whole code)
<div id="ck-builder-host" style="height: 100vh"></div>
<script type="module">
import { Craftkit } from 'https://cdn.craftkit.dev/embed/v1/index.js';
// or: import { Craftkit } from '@craftkit/embed';
const ck = Craftkit.init({
publishableKey: 'ck_pk_live_aB3xQ7…',
debug: false,
});
// 1) Ask their backend for a session token
const session = await fetch('/api/craftkit/embed-session', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ templateId: 'partner-template-9999', mode: 'edit' }),
}).then(r => r.json());
// 2) Mount the builder
const builder = ck.mountBuilder({
container: '#ck-builder-host',
sessionToken: session.session_token,
height: '100%',
autoResize: true,
refresh: async () => {
const r = await fetch('/api/craftkit/embed-session/refresh', {
method: 'POST',
body: JSON.stringify({ renewToken: builder.renewToken }),
}).then(r => r.json());
return r.session_token;
},
});
// 3) React to events
builder.on('ready', ({ sessionId }) => console.log('ready', sessionId));
builder.on('template.published', ({ templateId, version, manifest }) => {
closeMyModal();
showToast(`Published v${version}`);
});
builder.on('close.requested', () => builder.destroy());
builder.on('error', (err) => console.error('Craftkit embed error', err));
// 4) Optional commands
builder.setTheme({ primaryColor: '#FF0066' });
builder.loadTemplate('other-template-id');
builder.setPreviewData({ booking: { id: '12345' } });
</script>Form-fill quickstart
Mounting the form embed is the same shape — different mount method,
different events. The session token must have scope.mode === 'fill'
and resolve to a published template (see 02-jwt-spec.md).
<div id="ck-form-host" style="height: 720px"></div>
<script type="module">
import { Craftkit } from '@craftkit/embed';
const ck = Craftkit.init({ publishableKey: 'ck_pk_live_aB3xQ7…' });
// 1) Ask their backend for a fill-mode session token
const session = await fetch('/api/craftkit/embed-session', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ templateId: 'invoice-v3', mode: 'fill' }),
}).then(r => r.json());
// 2) Mount the form
const form = ck.mountForm({
container: '#ck-form-host',
sessionToken: session.session_token,
height: '100%',
autoResize: true,
refresh: async () => {
const r = await fetch('/api/craftkit/embed-session/refresh', {
method: 'POST',
body: JSON.stringify({ renewToken: form.renewToken }),
}).then(r => r.json());
return r.session_token;
},
onCompleted: ({ renderId, downloadUrl, source }) => {
console.log('Document ready', { renderId, downloadUrl, source });
},
onFailed: ({ message, cause }) => console.error('Form failed', cause, message),
});
form.on('ready', ({ sessionId }) => console.log('form ready', sessionId));
form.on('field.changed', ({ key, value }) => console.log(key, value));
</script> FormInstance — the API surface you'll use
interface FormInstance {
// Lifecycle
on(event: FormEventType, handler): () => void;
off(event: FormEventType, handler): void;
destroy(): void;
// Imperative commands
setValue(key: string, value: unknown): void;
setValues(values: Record<string, unknown>): void;
submit(): Promise<FormCompletedEvent>; // programmatic submit
reset(): void;
setReadOnly(readOnly: boolean): void;
// Datasets (see below)
setDatasets(datasets: Record<string, EagerDataset>): void;
declareDatasets(datasets: Record<string, LazyDatasetSpec>): void;
setDatasetItems(key: string, items: Array<{ id: string; label: string }>): void;
applyDatasetItem(key: string, values: Record<string, unknown>): void;
}
type FormEventType =
| 'ready' // iframe hydrated
| 'submit' // pre-submit; interceptable (Stripe-style)
| 'form.completed' // post-submit success (default OR partner_supplied)
| 'form.failed' // post-submit failure
| 'form.invalid' // client-side validation failure (no submit fired)
| 'field.changed' // debounced 300ms
| 'dataset.search' // lazy dataset queried by user
| 'dataset.item.requested'
| 'session.expiring' | 'session.refreshed' | 'session.expired'
| 'close.requested' | 'error';The full TypeScript shape (event payloads, EagerDataset, etc.) lives in
packages/embed/src/index.ts.
Stripe-style submit interception
By default, the iframe enqueues the render and displays the PDF inline. A parent handler can claim the submit and supply its own PDF instead. Identical UX from the user's perspective — they always see the document in the same iframe, only the PDF source changes.
form.on('submit', async (e) => {
// e = { data, datasetSelection, requestId, preventDefault, complete, fail }
if (!needsCustomSigning(e.data)) return; // let default render proceed
e.preventDefault(); // claim the submit (within 500ms)
try {
const signed = await mySigningService(e.data);
const pdf = await myRenderPipeline(signed);
await e.complete({ pdfUrl: pdf.url, metadata: { signedBy: 'acme' } });
// iframe now shows pdf.url; emits form.completed { source: 'partner_supplied' }
} catch (err) {
await e.fail({ message: err.message, cause: err });
}
});The interception window is 500ms after craftkit.form.submit fires.
A synchronous e.preventDefault() always wins. Once claimed, the partner
has up to 60s to call e.complete() or e.fail() before the iframe
surfaces a partner_timeout error.
Eager dataset prefill
Datasets are partner-supplied lookups (bookings, contacts, etc.) that
let the user pick an item and auto-fill the form. All dataset content
stays client-side — Craftkit only sees the id of the item the user
picked (stored on render.dataset_selection for audit).
form.on('ready', () => {
form.setDatasets({
bookings: {
label: 'Pick a booking',
items: myBookings.map(b => ({
id: b.id, // internal correlation
label: `${b.customer} — ${b.month}`, // shown in dropdown
values: { // shallow-merged into form state
'customer.name': b.customer,
'booking.startDate': b.startDate,
},
})),
},
});
});For huge or per-user-authorized datasets, declare a lazy dataset and
respond to dataset.search / dataset.item.requested events. Single-
select per dataset; multi-select for loop fields is a phase-3 follow-up.
Full design in 12-form-route.md §7c.
Public API surface
namespace Craftkit {
function init(config: {
publishableKey: string;
debug?: boolean;
iframeOrigin?: string; // default https://embed.craftkit.dev
onError?: (err: CraftkitError) => void;
}): CraftkitClient;
}
interface CraftkitClient {
mountBuilder(opts: MountBuilderOptions): BuilderInstance;
mountForm(opts: MountFormOptions): FormInstance;
// (future: mountTemplatePicker, mountRenderHistory, mountUsageDashboard…)
}
interface MountBuilderOptions {
container: string | HTMLElement;
sessionToken: string;
height?: 'auto' | string | number;
autoResize?: boolean;
loadingComponent?: HTMLElement | string;
refresh: () => Promise<string>;
sandboxFlags?: string;
allowFullscreen?: boolean;
}
interface BuilderInstance {
readonly sessionId: string;
readonly templateId: string | null;
readonly renewToken: string;
readonly iframeElement: HTMLIFrameElement;
on<E extends keyof BuilderEvents>(
event: E,
handler: (payload: BuilderEvents[E]) => void
): () => void;
off<E extends keyof BuilderEvents>(event: E, handler: Function): void;
setTheme(theme: Partial<{ primaryColor: string; logoUrl: string }>): void;
loadTemplate(externalId: string): Promise<void>;
setPreviewData(data: Record<string, unknown>): void;
triggerSave(): Promise<{ version: number }>;
triggerPublish(): Promise<{ version: number }>;
focus(): void;
destroy(): void;
isDestroyed(): boolean;
}
interface BuilderEvents {
'ready': { sessionId: string; templateId: string | null };
'template.saved': { templateId: string; version: number; manifest: VariableManifest };
'template.published': { templateId: string; version: number; manifest: VariableManifest };
'variable.inserted': { key: string; type: string; source: 'catalog' | 'custom' };
'variable.removed': { key: string };
'session.expiring': { secondsRemaining: number };
'session.refreshed': { newExpiresAt: string };
'session.expired': {};
'height.changed': { heightPx: number };
'close.requested': {};
'error': CraftkitError;
}
interface CraftkitError {
code:
| 'session_invalid'
| 'session_expired'
| 'origin_not_allowed'
| 'permission_denied'
| 'iframe_load_failed'
| 'refresh_failed'
| 'rate_limited'
| 'unknown';
message: string;
recoverable: boolean;
}Internal architecture
┌─────────────────── Partner page ────────────────────┐
│ │
│ CraftkitClient │
│ ├─ origin allowlist check │
│ ├─ event bus (Map<event, Set<handler>>) │
│ └─ BuilderInstance │
│ ├─ creates <iframe sandbox="allow-scripts │
│ │ allow-forms allow-same-origin"> │
│ ├─ window.addEventListener('message', …) │
│ │ · validates event.source === iframe.cw │
│ │ · validates event.origin === embed orig. │
│ │ · validates payload.type prefix │
│ ├─ refresh scheduler (clears on destroy) │
│ ├─ command dispatcher (postMessage with reqId │
│ │ and Promise resolution by reqId) │
│ └─ MutationObserver on container for cleanup │
│ │
└──────────────────────┬───────────────────────────────┘
│ postMessage protocol
┌──────────────────────▼───────────────────────────────┐
│ embed.craftkit.dev/builder?… │
│ │
│ EmbedBridge (other side of the same protocol) │
│ ├─ token validator │
│ ├─ command handler (load, theme, preview, …) │
│ └─ event emitter (ready, saved, published, …) │
│ │
│ ↓ │
│ Builder UI (the same Tiptap editor as native, │
│ but with catalog-aware variable picker, no chrome) │
│ │
└───────────────────────────────────────────────────────┘Security guarantees
- Strict origin matching — only accepts messages from
iframeOrigin(defaulthttps://embed.craftkit.dev); silently drops everything else - Source pinning —
event.source === iframe.contentWindowon every message - Type prefix gate — every event must start with
craftkit.; foreign ignored - Iframe sandboxed — narrowest permissions that still let editor work
- Auto-cleanup — when container element is removed from DOM, SDK detects via MutationObserver and tears down listeners (no memory leaks)
- No global pollution — everything in returned instances; no
window.craftkit
Last revised: 2026-05-02