Craftkitdocs

postMessage protocol

Bidirectional event bus between host page and iframe.

05 — postMessage Protocol

The bidirectional event bus between the partner's parent page and Craftkit's embedded iframe. The same protocol serves both the builder embed and the form-fill embed; form-only message types are tagged in the tables below.

Wire format: every message is a JSON object: { type, version: 1, payload, requestId? }.

Origin pinning:

  • Iframe accepts only messages from validated parent origins
  • Parent (via SDK) accepts only messages from https://embed.craftkit.dev (or partner-configured iframeOrigin)
  • All other messages silently dropped

Parent → iframe (commands)

Type Payload Purpose
craftkit.token.refresh { token } Hand a new JWT just before expiry
craftkit.template.load { externalId } Switch to editing a different template
craftkit.theme.update { primaryColor?, logoUrl? } Legacy — superseded by craftkit.appearance.set
craftkit.appearance.set { appearance: Partial<Appearance> } Live appearance update — variables, rules, theme, density, layout, fonts. See embed-styling guide.
craftkit.preview.data { data } Inject custom sample data for live preview
craftkit.session.terminate {} Force-end the session (after revoke)
craftkit.command.save { requestId } Trigger save (response: craftkit.response.save)
craftkit.command.publish { requestId } Trigger publish (response: craftkit.response.publish)
craftkit.focus {} Focus the editor
craftkit.form.submit (form) { requestId } Programmatically trigger a submit (mirror of clicking the form's submit button). Same ParentCommandPayload arm as { command: 'form.submit' }.
craftkit.form.complete (form) { requestId, pdfUrl, metadata? } Reply to an intercepted craftkit.form.submit event — tells the iframe to display the partner-rendered PDF inline
craftkit.form.fail (form) { requestId, message, cause? } Reply to an intercepted submit — tells the iframe to show an error state
craftkit.form.set_value (form) { key, value } Set one form field
craftkit.form.set_values (form) { values } Bulk-set form fields (shallow merge)
craftkit.form.reset (form) {} Clear all form values back to defaults/prefill
craftkit.form.set_read_only (form) { readOnly: boolean } Toggle read-only mode on the form
craftkit.dataset.set (form) { datasets: Record<string, EagerDataset> } Push complete eager datasets to the form
craftkit.dataset.declare (form) { datasets: Record<string, LazyDatasetSpec> } Declare lazy datasets — iframe will emit dataset.search as user types
craftkit.dataset.items (form) { key, items: [{ id, label }] } Reply to a craftkit.dataset.search request
craftkit.dataset.apply_item (form) { key, values } Reply to a craftkit.dataset.item.requested — supplies the full record to merge into form state

Iframe → parent (events)

Type Payload When
craftkit.ready { sessionId, templateId } Iframe finished hydrating
craftkit.template.saved { templateId, version, manifest } User clicked Save
craftkit.template.published { templateId, version, manifest } User clicked Publish
craftkit.variable.inserted { key, type, source } After a variable is added
craftkit.variable.removed { key } After a variable is removed
craftkit.session.expiring { secondsRemaining } ~30s before JWT exp
craftkit.session.refreshed { newExpiresAt } After token replacement
craftkit.session.expired {} JWT exp passed without refresh
craftkit.height.changed { heightPx } For autosizing iframe (optional)
craftkit.close.requested {} User clicked the close button
craftkit.appearance.applied { acceptedKeys, droppedKeys } Ack for craftkit.appearance.set — lists fields the iframe accepted vs dropped (e.g. unknown selectors)
craftkit.error CraftkitError Any failure surface
craftkit.response.save { requestId, version? } Response to save command
craftkit.response.publish { requestId, version? } Response to publish command
craftkit.form.submit (form) { data, datasetSelection, requestId } Pre-submit event. Interceptable: parent has 500ms to reply with craftkit.form.complete or craftkit.form.fail to claim the submit. No reply within 500ms → iframe POSTs to /v1/embed/form-submit/:sessionId itself.
craftkit.form.completed (form) { renderId, downloadUrl, data, datasetSelection, source: 'default' | 'partner_supplied' } Render finished and is displayed inline
craftkit.form.failed (form) { message, cause?, renderId?, source } Submit or render failed; iframe shows error state
craftkit.form.invalid (form) { issues: ZodIssue[] } Client-side validation rejected the submit; no submit fired
craftkit.field.changed (form) { key, value } Debounced 300ms; useful for parent-side draft sync
craftkit.dataset.search (form) { key, query, requestId } User typed into a lazy dataset combobox; reply with craftkit.dataset.items
craftkit.dataset.item.requested (form) { key, itemId, requestId } User picked an item from a lazy dataset; reply with craftkit.dataset.apply_item carrying its full values

Request/response pattern

For commands that need a result, use requestId:

// Parent → iframe
postMessage({
  type: 'craftkit.command.publish',
  version: 1,
  requestId: 'req_xyz',
});

// Iframe → parent (matches by requestId)
postMessage({
  type: 'craftkit.response.publish',
  version: 1,
  requestId: 'req_xyz',
  payload: { version: 5 },
});

The SDK exposes this via builder.triggerPublish() returning a Promise.

Form-submit interception

craftkit.form.submit (iframe → parent) is the only event in the protocol that is interceptable — replying with craftkit.form.complete or craftkit.form.fail matching the same requestId within 500ms causes the iframe to skip its default POST /v1/embed/form-submit call and display the partner-supplied PDF instead. The SDK exposes this as e.preventDefault() + e.complete({ pdfUrl }) / e.fail({ message }) on the submit event handler. After 500ms with no reply, the iframe proceeds with the default render. After 60s with no reply on a claimed submit, the iframe surfaces craftkit.form.failed with cause: 'partner_timeout'.

Validation rules (both sides)

Every message is validated:

  1. event.source === iframe.contentWindow (parent side) OR event.source === window.parent (iframe side)
  2. event.origin === expectedOrigin (strict equality)
  3. payload is an object with type: string
  4. type starts with craftkit.
  5. version matches expected (currently always 1)

Anything failing → silently dropped, NOT logged to user (avoids information leak to malicious origins).

Versioning

The version field allows protocol evolution. When v2 ships:

  • v1 SDKs continue working (server tolerates v1)
  • v2 SDKs prefer v2 but fall back to v1
  • The iframe negotiates by inspecting first message's version

Why postMessage AND webhooks

postMessage Webhook
Latency Sub-ms 100ms–10s
Reliability Lost on tab close, navigation Durable, retried
Security Origin-pinned HMAC-signed
Use for UX (close modal, show toast) Source of truth (DB writes)

The rule: webhook is the source of truth. postMessage is for snappy UX. Never trust postMessage alone for state changes that matter.


Last revised: 2026-05-02