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-configurediframeOrigin) - 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:
event.source === iframe.contentWindow(parent side) ORevent.source === window.parent(iframe side)event.origin === expectedOrigin(strict equality)payloadis an object withtype: stringtypestarts withcraftkit.versionmatches 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