Craftkitdocs

Form-fill embeddable

Drop-in form for end-users to fill template variables and produce documents — with optional Stripe-style submit interception and client-side dataset prefill.

12 — Form-fill embeddable

A second embeddable surface that consumes a published Craftkit template and renders a form for filling in its variables. By default, submitting the form renders the document inline in the same iframe. Partners can intercept the submit to alter the data, sign, watermark, or render server-side themselves — without breaking the in-iframe document display for the end-user.

It is the runtime sibling of the builder embed: same iframe model, same JWT mint flow, same SDK shape — but the user is filling values in, not designing.


1. Why this exists

Today there are two ways to instantiate a document from a template:

  1. Programmatic — partner backend calls POST /v1/templates/:slug/render with a JSON data object. Works, but every partner has to build their own form UI and validation against the template's variable manifest.
  2. Inside the dashboard — a CraftKit-hosted UI that's not white-labeled or embeddable.

The form-fill embeddable is a drop-in third option:

  • For embed partners: ship a "Create document" button in their app that pops a Craftkit-hosted form for the same templates their users designed via the builder embed. No render-API plumbing needed for the manual flow.
  • For CraftKit internal use: same component powers the dashboard's "Create document from template" surface, so we don't maintain two form UIs in parallel.

A partner can use either flow (programmatic, form, or both) per template. Both create rows in the same render table, so the partner's renders list / admin UI / API surfaces them uniformly regardless of how they were initiated.


2. User flows

2a. Partner-hosted manual document creation (default)

End-user sees:                  Partner's CRM
  [+ New invoice] →             opens Craftkit form embed
                                →  optional: pick from a "bookings" dropdown
                                   (partner-supplied dataset; auto-prefills)
                                →  user fills / completes the remaining fields
                                →  clicks "Create"
                                →  iframe enqueues a render
                                →  document appears in the same iframe
                                →  parent receives `craftkit.form.completed`
                                   { renderId, downloadUrl }
                                →  partner's CRM links to the PDF (or doesn't,
                                   the user already has it on screen)

2b. Partner intercepts to alter the document (Stripe-style)

…                                user clicks "Create"
                                →  parent's `submit` handler fires FIRST
                                →  handler calls e.preventDefault()
                                →  handler signs / watermarks / sends to
                                   compliance pipeline / etc.
                                →  handler calls form.complete({ pdfUrl })
                                →  iframe displays the partner-supplied PDF
                                →  render row recorded with
                                   source='partner_supplied'

2c. Internal CraftKit "Create document"

Same form component mounted inside the dashboard. No partner JWT — uses the session cookie. Replaces today's per-template FormComponent slot in the template registry.

2d. Dataset-driven prefill

Partner's app already has the user's data loaded (bookings, contacts, etc). After the iframe boots, the partner's JS pushes datasets to it via postMessage. The form shows a dropdown above the fields; selecting an item merges its values into the form state. User completes the rest manually if the dataset only fills part of the form. CraftKit never sees the dataset contents — the data flows parent-page → iframe directly. See §7c.


3. Architecture

            ┌─────────────────────────────────────────┐
            │ Partner backend                         │
            │   POST /v1/embed/sessions               │
            │   → returns session_token (JWT)         │
            └────────────────┬────────────────────────┘
                             │ session_token
                             ▼
       <iframe src="…/embed/form?session_token=…">
            │
            ▼
   ┌────────────────────────────────────────────────────────┐
   │ Craftkit form route (Next.js)                          │
   │  1. Verify JWT, load session + template                │
   │  2. Resolve variable schema                            │
   │     (catalog if attached, else manifest)               │
   │  3. Render auto-form from schema                       │
   │  4. Listen for parent-pushed datasets                  │
   │  5. On submit:                                         │
   │     a. emit `craftkit.form.submit` to parent           │
   │     b. await preventDefault deadline (~500ms)          │
   │     c. if NOT intercepted → POST /v1/embed/form-submit │
   │        → poll → display PDF inline → emit `completed`  │
   │     d. if intercepted → wait for form.complete/fail    │
   │        → display PDF inline → emit `completed`         │
   └────────────────────────────────────────────────────────┘

Key reuse — most of the supporting surface already exists:

Need Existing New
JWT mint, signing, refresh apps/web/src/lib/embed/server.ts scope mode 'fill'
postMessage bus packages/embed-core/src/events.ts form events (see §9)
Variable schema templateVersion.variablesManifest, variableCatalog walker → form spec
Render trigger POST /v1/templates/:slug/render wrap in /v1/embed/form-submit/:sessionId
Render row render table new source column
SDK mount surface Craftkit.mountBuilder() Craftkit.mountForm()

4. JWT changes

Extend scope.mode to include 'fill':

"scope": {
  "mode": "fill",                    // NEW
  "template_id": "ck_tpl_…",         // required (form needs a template)
  "template_external_id": "…",
  "version_number": 7                // optional; defaults to current published
}

Add a form block:

"ck": {,
  "form": {
    "prefill": { "customer.name": "Ada Lovelace" },
    "show_preview": true,            // side-by-side live preview pane
    "show_document_after_submit": true,  // false = iframe closes/blanks after submit
    "redirect_url": null             // optional: partner's "back" link
  }
}

Add to permissions:

"permissions": {,
  "submit_form": true,               // false = read-only / preview-only
  "save_form_draft": false           // future: save partially-filled forms
}

permissions.submit_form=true is required to call /v1/embed/form-submit; the iframe still loads with submit_form=false so partners can use the form UI as a preview affordance (e.g., let the user inspect what the request would look like without actually consuming render quota).

No JWT field for datasets. Datasets flow client-side only (see §7c).

No submit_mode field. Submission is always observable + interruptible via the SDK callback (see §7); the partner chooses at runtime whether to let the default render proceed or take over.


5. Route + page contract

Path: /embed/form?session_token=…&theme=…&appearance=…

File: apps/web/src/app/(embed)/embed/form/page.tsx

Mirrors /embed/builder/page.tsx:

  1. Verify session via verifyAndLoadSession().
  2. Resolve templateId from scope.template_id.
  3. Load template + the requested templateVersion (latest published if version_number omitted).
  4. Build the form spec (see §6).
  5. Compose appearance (existing normalizeAppearance flow — fonts, theme tokens, density, locale all work unchanged).
  6. Mount <TemplateFormMount> with the spec, prefill, locale, branding, and a server-action handle for the render call.

The same AppearanceBridge listens for runtime appearance updates; identical security model (origin pinning, allowed-origins gate).


6. Form rendering — schema → UI

Single source of truth ranking:

  1. Catalog (session.catalog) — partner-supplied; richer (labels, previewData, descriptions, namespaces, loops). Use whenever present.
  2. Variable manifest (templateVersion.variablesManifest) — the compiler's output. Every published template has one. Used when no catalog is attached (CraftKit-internal templates, partner with no catalog_ref).

The walker produces a flat FormSpec:

interface FormSpec {
  sections: FormSection[];   // grouping = catalog namespace OR top-level path segment
  loops: FormLoop[];         // 1:1 with catalog/manifest loops
  required: Set<string>;     // keys with required=true OR referenced inside Handlebars `{{#if}}` predicates
}

interface FormField {
  key: string;
  label: string;
  dataType: VariableDataType;   // text | longtext | number | currency | date | datetime | boolean | image | url | email
  format?: string;              // forwarded to the input (e.g. currency:EUR)
  description?: string;         // tooltip
  required: boolean;
  defaultValue?: ScalarPrimitive;
  previewData?: ScalarPrimitive;
  options?: { value: string; label: string }[]; // future: enum support
}

interface FormLoop {
  key: string;
  label: string;
  itemFields: FormField[];
  minItems?: number;            // future
  maxItems?: number;            // future
}

Per-dataType input mapping (initial pass):

dataType Component Validation
text, url, email <Input> maxLength 5000; type-specific regex
longtext <Textarea> maxLength 50000
number <Input type="number"> finite, optional integer flag from format
currency <Input> with currency suffix finite; format='money:EUR' parsed
date <DatePicker> ISO; honors locale
datetime <DateTimePicker> ISO
boolean <Checkbox> n/a
image <FileUpload accept="image/*"> size limit from limits.max_uploads_bytes

Validation runs client-side (zod schema synthesised from the manifest — the same compiler the render endpoint already uses) for instant feedback, then server-side at submit (canonical).

previewData doubles as the placeholder in the input, so the user sees what shape is expected.


7. Submit lifecycle

7a. Default flow

Form submit
  → client zod validate                                     (fail → highlight fields, abort)
  → emit `craftkit.form.submit` { data, requestId } to parent
  → wait up to 500ms for parent to claim the submit
  → no claim → POST /v1/embed/form-submit/:sessionId        body: { data }
                → server validates JWT, scope, permissions
                → enqueues render via existing pipeline
                → returns { renderId, pollUrl }
  → poll until 'succeeded' | 'failed'
  → on success: display PDF inline + emit `craftkit.form.completed`
                  { renderId, downloadUrl, data, source: 'default' }
  → on failure: display error inline + emit `craftkit.form.failed`
                  { issues: [...], renderId, source: 'default' }

/v1/embed/form-submit/:sessionId exists so the partner doesn't expose a public render endpoint to the iframe — the JWT is the auth, not a partner API key. Internally it calls the same enqueue path that POST /v1/templates/:slug/render does, but it tags the resulting render row with source = 'form' and embed_session_id = :sessionId for auditing.

7b. Interception (partner takes over)

const form = ck.mountForm({ container, sessionToken });

form.on('submit', async (e) => {
  // e = { data, requestId, preventDefault, complete, fail }
  if (!needsAlteration(e.data)) return;        // let default proceed

  e.preventDefault();                          // claim the submit

  try {
    const altered = await mySigningService(e.data);
    const pdf = await myRenderPipeline(altered);
    await e.complete({ pdfUrl: pdf.url, metadata: { signedBy: 'acme' } });
    // iframe now displays pdf.url inline; emits `craftkit.form.completed`
    //   with { renderId, downloadUrl: pdfUrl, data, source: 'partner_supplied' }
  } catch (err) {
    await e.fail({ message: err.message });
    // iframe shows error state; emits `craftkit.form.failed`
  }
});

Critical UX rule: the iframe always displays the document inline, no matter who produced it. The partner-intercepted path doesn't blank the iframe back to the form — it just sources the PDF differently. From the end-user's perspective, the click-to-document journey is identical.

7c. Renders list integration

Every form submission, intercepted or not, creates a render row:

ALTER TABLE render ADD COLUMN source text NOT NULL DEFAULT 'api';
-- 'api'              → POST /v1/templates/:slug/render
-- 'form'             → POST /v1/embed/form-submit (default flow)
-- 'partner_supplied' → form intercepted; partner provided the PDF URL
-- 'dashboard'        → internal CraftKit dashboard

ALTER TABLE render ADD COLUMN embed_session_id text NULL;
ALTER TABLE render ADD COLUMN dataset_selection jsonb NULL;
-- e.g. { bookings: 'bk_123', contacts: 'ct_456' } — what items the user
-- picked from each dataset, if any. Lets the partner answer "this doc
-- was generated from booking X" via the renders API.

Existing GET /v1/renders endpoint surfaces these uniformly. Partner- supplied PDFs store the URL in download_url directly without us fetching/re-hosting unless they explicitly opt in via a future mirror: true flag.


7c. Dataset prefill

Datasets let the end-user pick from a list (e.g., bookings, contacts) and auto-populate the form. All dataset content lives client-side. CraftKit never receives or stores it.

Eager pattern (default)

The partner's app already has the data loaded — they just push it to the iframe after ready:

const form = ck.mountForm({ container, sessionToken });

form.on('ready', () => {
  form.setDatasets({
    bookings: {
      label: 'Pick a booking',
      labelField: 'label',                  // which property of the items to display
      items: myBookings.map(b => ({
        id: b.id,                           // internal correlation only — never displayed
        label: `${b.customer}${b.month}`,
        values: {
          'customer.name': b.customer,
          'booking.startDate': b.startDate,
          'travelers': b.travelers,
        },
      })),
    },
    contacts: { label: 'Pick a contact', items: [...] },
  });
});

The iframe renders one combobox per dataset above the form fields. Picking an item shallow-merges item.values into the form state. The user edits / completes / submits as normal.

Suitable for partners with up to a few thousand items per dataset. postMessage handles MB-scale payloads, but iframe memory and dropdown render performance start to matter past that point.

Lazy pattern (advanced)

For huge datasets or partners that want server-side filtering / per-user authorization:

form.declareDatasets({
  bookings: { label: 'Pick a booking', searchable: true, minQueryLength: 2 },
});

// Iframe shows an async combobox; emits `dataset.search` as user types
form.on('dataset.search', async ({ key, query }) => {
  const items = await myApi.search(key, query);   // partner backend
  form.setDatasetItems(key, items);               // tiny payload — { id, label } per item
});

// On select, iframe asks for the full record
form.on('dataset.item.requested', async ({ key, itemId }) => {
  const item = await myApi.get(key, itemId);
  form.applyDatasetItem(key, item.values);        // merged into form state
});

Two-tier load: dropdown shows just id + label, full values only fetched when the user picks one. Works for arbitrarily large datasets.

  • Only labels shown, never IDs. The id exists for correlation (partner can answer "this submission came from booking X" via render.dataset_selection) but is invisible to the end-user.
  • Single-select per dataset (one booking, one contact). Multi-select is a phase-3 follow-up needed for filling loop fields like travelers[*].
  • Optional — datasets are an enhancement; the form works without any.
  • Mergeable — picking from one dataset doesn't reset values from another. Partner controls overlap by being thoughtful about which fields each dataset supplies.

Why no permission flag

Datasets never touch CraftKit's backend, never consume our resources, and expose no data we don't already see (the values flow into the form, the form submits the values, we see the same payload either way). There's nothing for us to gate. If a partner wants to disable datasets for a specific session, they simply don't call setDatasets() / declareDatasets() in that code path.

Selection metadata in renders

When the user submits, the form embed includes the picked items in the submit payload:

POST /v1/embed/form-submit/:sessionId
{
  "data": { "customer.name": "Acme Corp", ... },
  "datasetSelection": {
    "bookings": "bk_123",
    "contacts": "ct_456"
  }
}

Stored on render.dataset_selection so the partner's renders list / API can answer "which dataset items produced this document" — useful for filtering ("show all docs generated from booking X") and audit.


8. SDK additions

interface CraftkitClient {
  mountBuilder(opts: MountBuilderOptions): BuilderInstance;
  mountForm(opts: MountFormOptions): FormInstance;     // NEW
}

interface MountFormOptions {
  container: string | HTMLElement;
  sessionToken: string;
  height?: string;
  autoResize?: boolean;
  refresh?: () => Promise<string>;
  showPreview?: boolean;          // override JWT setting
  // Convenience callbacks — equivalent to .on(eventName, handler)
  onSubmit?: (e: FormSubmitEvent) => void | Promise<void>;
  onCompleted?: (e: FormCompletedEvent) => void;
  onFailed?: (e: FormFailedEvent) => void;
  onChange?: (e: FormChangeEvent) => void;
}

interface FormInstance {
  on(event: FormEventType, handler: (...args: any[]) => void): void;
  off(event: FormEventType, handler: (...args: any[]) => void): void;
  destroy(): void;

  // Imperative commands
  setValue(key: string, value: unknown): void;
  setValues(values: Record<string, unknown>): void;
  submit(): Promise<FormCompletedEvent>;
  reset(): void;
  setReadOnly(readOnly: boolean): void;

  // Datasets
  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;
}

interface FormSubmitEvent {
  data: Record<string, unknown>;
  datasetSelection: Record<string, string>;     // datasetKey → itemId
  requestId: string;
  /** Claim the submit; default render is suppressed. */
  preventDefault(): void;
  /** Tell the iframe to display the partner-rendered PDF inline. */
  complete(args: { pdfUrl: string; metadata?: Record<string, unknown> }): Promise<void>;
  /** Tell the iframe to show an error state. */
  fail(args: { message: string; cause?: unknown }): Promise<void>;
}

interface FormCompletedEvent {
  renderId: string;
  downloadUrl: string;
  data: Record<string, unknown>;
  datasetSelection: Record<string, string>;
  source: 'default' | 'partner_supplied';
}

interface EagerDataset {
  label: string;
  labelField?: string;                // defaults to 'label'
  items: Array<{
    id: string;
    label: string;
    values: Record<string, unknown>;
  }>;
}

interface LazyDatasetSpec {
  label: string;
  searchable?: boolean;
  minQueryLength?: number;
  debounceMs?: number;                // defaults to 300
}

type FormEventType =
  | 'ready'
  | 'submit'                          // pre-submit; interceptable
  | 'form.completed'                  // post-submit success
  | 'form.failed'                     // post-submit failure
  | 'form.invalid'                    // client-side validation failure
  | 'field.changed'
  | 'dataset.search'                  // lazy dataset queried
  | 'dataset.item.requested'          // lazy dataset item picked
  | 'session.expiring'
  | 'session.expired'
  | 'session.refreshed'
  | 'close.requested'
  | 'error';

Implementation sits in packages/embed/src/index.ts, alongside BuilderInstance. Shared transport, origin gate, and refresh logic.


9. PostMessage additions

Add to packages/embed-core/src/events.ts:

Iframe → parent

Type Payload
craftkit.form.submit { data, datasetSelection, requestId } — the interceptable pre-submit event
craftkit.form.completed { renderId, downloadUrl, data, datasetSelection, source }
craftkit.form.failed { message, cause?, renderId?, source }
craftkit.form.invalid { issues: ZodIssue[] } (client-side validation only)
craftkit.field.changed { key, value } (debounced 300ms)
craftkit.dataset.search { key, query, requestId }
craftkit.dataset.item.requested { key, itemId, requestId }

Parent → iframe

Type Payload
craftkit.form.set_value { key, value }
craftkit.form.set_values { values: Record<string, unknown> }
craftkit.form.submit { requestId } (response: craftkit.response.form_submit)
craftkit.form.reset {}
craftkit.form.set_read_only { readOnly: boolean }
craftkit.form.complete { requestId, pdfUrl, metadata? } — response to a partner-claimed submit
craftkit.form.fail { requestId, message, cause? } — response to a partner-claimed submit
craftkit.dataset.set { datasets: Record<string, EagerDataset> }
craftkit.dataset.declare { datasets: Record<string, LazyDatasetSpec> }
craftkit.dataset.items { key, items: [{ id, label }] } — response to dataset.search
craftkit.dataset.apply_item { key, values } — response to dataset.item.requested

The existing craftkit.preview.data is unchanged — it's a separate axis (injecting sample values into a preview pane) and works in both builder and form embeds.


10. Security

  • JWT scope check: scope.mode === 'fill' is the only mode allowed to POST /v1/embed/form-submit. Reject 'edit' | 'create' | 'view' with 403.
  • Render quota: every default-flow form submit charges the partner's render quota, same as a programmatic call. Partner-intercepted submissions don't (we never enqueued the render); they create a render row tagged source='partner_supplied' for audit, with no compute charge.
  • Prefill validation: form.prefill keys are validated against the variable schema at mint time and dropped if unknown. No surprise data injected at run time.
  • Partner-supplied PDF URLs: validated as HTTPS, MIME-checked (application/pdf) on first load via HEAD, and stored as a reference only. We don't proxy or rehost. If the URL 404s or changes, the renders-list link breaks — partner's responsibility.
  • Datasets: never reach our backend. Logged metadata: dataset keys used (e.g., ["bookings", "contacts"]) and the id of any item the user picked. No values are observable to us beyond what ends up in the submitted form payload (which we already see).
  • File uploads (image fields): same upload route used by the builder, same per-session size cap (limits.max_uploads_bytes). Files are scoped to the session id; pruned when the session expires.
  • CSRF: form submit is JWT-bearer-authed, not cookie-authed; no CSRF token needed.
  • Origin pinning: identical to builder embed — the iframe accepts postMessages only from validated parent origins.

11. Edge cases & open questions

Case Plan
Partner publishes a new template version mid-fill Pin the form to the version it loaded with; warn the user; offer "reload to latest" via banner.
User refreshes mid-fill Form state lost (session is in-memory only by spec). Datasets also lost — partner's ready handler re-pushes. Partial-save behind permissions.save_form_draft is a phase-3 follow-up.
Manifest has loops the form UI can't render yet Render the loop as JSON textarea fallback; flag in craftkit.error.
Render fails after submit Iframe shows error state, surfaces as craftkit.form.failed with cause: 'render_failed'. User can retry without re-typing.
Partner intercepts but never calls complete() or fail() After 30s the iframe shows a "Still working…" indicator; after 60s it surfaces craftkit.form.failed with cause: 'partner_timeout' and offers a retry button (which re-emits craftkit.form.submit).
Catalog and manifest disagree (partner shipped catalog with extra/missing fields) Catalog wins for what the user sees; submit is validated against the manifest and gracefully drops unknown catalog keys before posting to the render endpoint.
Internal use (no JWT) The <TemplateFormMount> component takes the spec directly; the dashboard route bypasses the JWT layer and calls the render endpoint with the user's session cookie.
Dataset selection conflicts with manual edits "Last write wins" in form state — picking a dataset overwrites overlapping fields without warning. A future enhancement could diff-and-confirm; not in scope.
Lazy dataset search returns no results Combobox shows "No matches"; user can still leave the field empty and fill manually.

12. Phased implementation

Phase 1 — minimum viable form + interception (1–2 weeks)

  • Schema: add 'fill' to scope.mode, permissions.submit_form, form block to JWT claims, mirror in embedJwtClaimsSchema.
  • DB: add render.source, render.embed_session_id, render.dataset_selection.
  • Server: extend verifyAndLoadSession to surface the form block.
  • Page: apps/web/src/app/(embed)/embed/form/page.tsx.
  • <TemplateFormMount> + auto-form generator over the manifest.
  • Submit handler: POST /v1/embed/form-submit/:sessionId.
  • Submit lifecycle with interception window (craftkit.form.submit, craftkit.form.complete, craftkit.form.fail, craftkit.form.completed, craftkit.form.failed).
  • In-iframe document display after completion (default + partner paths).
  • SDK: Craftkit.mountForm() with submit / completed / failed events and complete() / fail() / preventDefault() on the submit event.
  • Eager datasets: setDatasets, craftkit.dataset.set, dropdown UI, shallow-merge into form state, dataset_selection recorded on render row.
  • Docs: this file plus a partner-facing quickstart in docs/embed/03-sdk.md.

Phase 2 — parity with builder embed (1 week)

  • Honor appearance.set / appearance.applied for theming.
  • Optional live-preview pane (PDF on the right, form on the left).
  • craftkit.field.changed events.
  • Per-field validation feedback (live).
  • Image upload field + per-session quota.
  • Lazy datasets: declareDatasets, dataset.search, dataset.item.requested, async combobox UI.

Phase 3 — convenience & resilience (open-ended)

  • Partial-save / draft form sessions (requires DB table).
  • Loop UI that supports add/remove/reorder rows visually.
  • setValue / setValues parent-side commands beyond datasets.
  • Multi-select datasets (for filling loop fields like travelers[*] from a list).
  • Optional mirror: true — for partner-supplied PDFs, fetch and re-host so the renders-list link keeps working if the partner's URL rotates.
  • Internalize the dashboard's "Create document" page on top of <TemplateFormMount> and retire per-template FormComponent slots from the template registry.
  • Server-side webhook on render completion (vs. iframe polling) so partners can react without the user keeping the tab open.

13. Open design questions

  1. mountForm separate from mountBuilder?Resolved. Separate. Different event surface, cleaner mental model. Sharing the iframe URL would force a runtime conditional inside the iframe, more painful than a sibling page.

  2. Default submit behavior?Resolved. Always renders + displays inline, always interceptable via e.preventDefault(). No JWT-level submit_mode toggle. Partner chooses at runtime per submit.

  3. Datasets stored server-side or client-only?Resolved. Client-only (parent → iframe via postMessage). CraftKit never sees dataset content. No dataset_ref, no embedDataset table, no permission flag.

  4. Partner-supplied PDFs: store URL or re-host? — Phase 1 stores URL only. Phase 3 adds opt-in mirror: true for partners that want CraftKit to keep the URL stable.

  5. Interception timeout window? — Default flow waits 500ms after emitting craftkit.form.submit for a preventDefault claim. Long enough that a synchronous handler always wins; short enough that the end-user doesn't notice a delay when nothing is intercepting. Configurable via mount option if 500ms turns out to be wrong in the field.

  6. Versioning of FormSpec? — No version field initially — the manifest is the schema and it carries its own shape. If the form-spec walker becomes a public SDK type later we'll add formSpec.version.