Failure modes
What can go wrong and how the iframe surfaces it.
08 — Failure-Mode UX
How the embedded builder behaves when things go wrong. Every failure mode has explicit, partner-aware UX. The taxonomy below covers both the builder embed and the form-fill embed — form-only codes are listed separately at the end.
Design principles
- Never crash the editor canvas — typed content is always recoverable
- Speak the partner's language — error copy uses partner's brand name
- Give one clear next action — two buttons max
- Bubble enough context to the partner — every visible error fires a
craftkit.errorevent with structured payload - Distinguish recoverable from terminal — soft banner vs replace canvas
Failure taxonomy
| Code | Severity | Recoverable? | Trigger |
|---|---|---|---|
session_invalid |
Terminal | No | JWT signature fails, tampered |
session_expired |
Recoverable | Yes (refresh) | Clock drift or refresh missed |
session_revoked |
Terminal | No | Partner clicked Revoke |
origin_not_allowed |
Terminal | No | Iframe loaded from unlisted host |
permission_denied |
Inline | Yes (degrade) | Tried to publish without rights |
catalog_field_missing |
Inline | Yes (remap) | Template uses key removed from catalog |
partner_suspended |
Terminal | No | Billing failure, ToS breach |
rate_limited |
Recoverable | Yes (wait) | Refresh storm |
network_offline |
Recoverable | Yes (retry) | Browser offline |
iframe_load_failed |
Terminal | No | Embed host unreachable |
render_quota_exhausted |
Inline | No | Project hit usage cap |
editor_state_corrupted |
Recoverable | Yes (reload draft) | Local autosave restore |
webhook_delivery_failed |
Background | Yes (auto-retry) | Partner endpoint down |
Form-flow-only codes
These are emitted by /embed/form, POST /v1/embed/form-submit/:sessionId,
and GET /v1/embed/renders/:id. HTTP codes given for the API responses;
several also surface on the embed error page or as craftkit.form.failed
postMessage events with the listed cause.
| Code | HTTP | Surface | Trigger |
|---|---|---|---|
wrong_mode |
403 | API | JWT scope.mode is not 'fill' but the request hit a form-mode-only route |
permission_denied |
403 | API | permissions.submit_form is false (re-uses the existing builder code with the form-specific reason) |
template_not_resolved |
404 | Embed page + API | Session has neither scope.template_id nor scope.template_external_id |
template_not_found |
404 | Embed page + API | Resolved template id doesn't exist or belongs to another project |
unpublished_template |
404 | API | Template exists but has no published version |
template_unpublished |
— | Embed page | Same as above, surfaced as EmbedError page code |
invalid_input_data |
400 | API | Submit data failed manifest validation (missing required, type mismatch, prefill key not in manifest) |
invalid_request |
400 | API | Body shape didn't match formSubmitRequestSchema |
invalid_json |
400 | API | Body wasn't valid JSON |
not_found |
404 | API | GET /v1/embed/renders/:id — render doesn't exist or doesn't belong to this session (no project-wide fallback) |
partner_timeout |
— | postMessage cause |
Parent claimed the submit via e.preventDefault() but never called complete() / fail() within 60s |
render_timeout |
— | postMessage cause |
Default-flow render didn't reach succeeded/failed within the 60s poll cap (1.5s cadence) |
render_failed |
— | postMessage cause |
Render pipeline returned status='failed' |
network |
— | postMessage cause |
Iframe lost connectivity while polling render status |
The 30s "still working…" indicator shown after a partner claims the
submit is informational, not an error code — the iframe only escalates
to partner_timeout at 60s.
Key failure modes (samples)
session_expired (recoverable)
Banner-style; editor stays interactive but read-only for ≤30s while refresh
attempts proceed. Background retry with exponential backoff (1s → 30s). After
30s of failure → upgrade to session_revoked UX.
⏳ Your session paused
We're reconnecting to {Partner}'s servers. Your work is saved.
[Retry now] Reconnecting in 4s… session_revoked / partner_suspended (terminal)
Replace canvas. "Return to {Partner}" button uses callbacks.on_close_url.
── Session ended ──
This editing session has ended. Don't worry — your last saved
version (v3, saved 4 minutes ago) is safe.
What happened: an administrator ended this session, or your
permissions changed.
[Return to {Partner}]
Need help? Mention reference: ck_sess_01HVR3… [Copy] permission_denied (inline, soft)
Toolbar publish button disabled with tooltip; inline banner appears when user attempts publish:
⚠ You don't have permission to publish.
Ask an admin to publish this template, or save it as
a draft to keep working.
[Save as draft] [Request publish] [Dismiss]Request publish fires embed.permission.publish_requested to partner —
they build approval workflow themselves.
catalog_field_missing (inline with remap)
Chip rendered as [⚠ customer.fullName ✕] with hover tooltip:
⚠ This field is no longer in your data catalog.
Original key: customer.fullName
Suggested: customer.name (95% match)
[Remap] [Remove] [Keep as-is]Persistent banner above toolbar:
⚠ 3 fields in this template aren't in your catalog. [Review →]
Publish blocked while missing-field chips exist (configurable via partner).
Remap creates a key_alias on the template version.
network_offline
Persistent banner; editor stays fully interactive (Tiptap is in-memory). Local autosave to IndexedDB. Flushes on reconnect.
⊘ You're offline. Changes are saved locally and will sync
when you're back online. [Retry now] editor_state_corrupted (recovery on mount)
↻ We found unsaved changes
Last edited 2 minutes ago. Recover them?
[Recover unsaved changes] [Discard] The ErrorSurface primitive
Every failure rendered through the same shape:
interface ErrorSurface {
variant: 'banner' | 'modal' | 'inline-tooltip' | 'replace-canvas';
severity: 'info' | 'warn' | 'error' | 'critical';
title: string; // partner-aware
body: string; // one sentence about what's safe
primaryAction: Action; // always present, always safe
secondaryAction?: Action;
diagnosticReference?: string; // ck_sess_… (copy-clickable)
partnerCallback?: string; // return URL from JWT
}Behavior contract:
- Always emits
craftkit.errorto parent before rendering - Always logs structured event to partner's webhook
- Never blocks already-typed content from being recovered
replace-canvasvariants offer offline-friendly export ("Copy your work as JSON")
The "nuclear option" — Copy-as-JSON
In ANY terminal failure, the user can click Copy work as JSON. They get
the full Tiptap document on their clipboard. Partner support pastes it back
into a fresh session via setInitialContent to recover.
Costs nothing to build, never advertised, occasionally saves a customer relationship.
Loop closed at the admin UI
Every visible failure → entry in Sessions → Recent with severity coding
(✓ ⚠ ✕ ⊘ ⊗). Partners debug their integration without ever asking Craftkit
support.
Last revised: 2026-05-02