Craftkitdocs

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 via npm 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

  1. Strict origin matching — only accepts messages from iframeOrigin (default https://embed.craftkit.dev); silently drops everything else
  2. Source pinningevent.source === iframe.contentWindow on every message
  3. Type prefix gate — every event must start with craftkit.; foreign ignored
  4. Iframe sandboxed — narrowest permissions that still let editor work
  5. Auto-cleanup — when container element is removed from DOM, SDK detects via MutationObserver and tears down listeners (no memory leaks)
  6. No global pollution — everything in returned instances; no window.craftkit

Last revised: 2026-05-02