Craftkitdocs

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

  1. Never crash the editor canvas — typed content is always recoverable
  2. Speak the partner's language — error copy uses partner's brand name
  3. Give one clear next action — two buttons max
  4. Bubble enough context to the partner — every visible error fires a craftkit.error event with structured payload
  5. 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.error to parent before rendering
  • Always logs structured event to partner's webhook
  • Never blocks already-typed content from being recovered
  • replace-canvas variants 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