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:
- Programmatic — partner backend calls
POST /v1/templates/:slug/renderwith a JSONdataobject. Works, but every partner has to build their own form UI and validation against the template's variable manifest. - 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:
- Verify session via
verifyAndLoadSession(). - Resolve
templateIdfromscope.template_id. - Load
template+ the requestedtemplateVersion(latest published ifversion_numberomitted). - Build the form spec (see §6).
- Compose appearance (existing
normalizeAppearanceflow — fonts, theme tokens, density, locale all work unchanged). - 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:
- Catalog (
session.catalog) — partner-supplied; richer (labels,previewData, descriptions, namespaces, loops). Use whenever present. - Variable manifest (
templateVersion.variablesManifest) — the compiler's output. Every published template has one. Used when no catalog is attached (CraftKit-internal templates, partner with nocatalog_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.
Dropdown UX rules
- Only labels shown, never IDs. The
idexists for correlation (partner can answer "this submission came from booking X" viarender.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
renderrow taggedsource='partner_supplied'for audit, with no compute charge. - Prefill validation:
form.prefillkeys 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'toscope.mode,permissions.submit_form,formblock to JWT claims, mirror inembedJwtClaimsSchema. - DB: add
render.source,render.embed_session_id,render.dataset_selection. - Server: extend
verifyAndLoadSessionto surface theformblock. - 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()withsubmit/completed/failedevents andcomplete()/fail()/preventDefault()on the submit event. - Eager datasets:
setDatasets,craftkit.dataset.set, dropdown UI, shallow-merge into form state,dataset_selectionrecorded onrenderrow. - 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.appliedfor theming. - Optional live-preview pane (PDF on the right, form on the left).
-
craftkit.field.changedevents. - 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/setValuesparent-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-templateFormComponentslots 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
mountFormseparate frommountBuilder? — 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.Default submit behavior? — Resolved. Always renders + displays inline, always interceptable via
e.preventDefault(). No JWT-levelsubmit_modetoggle. Partner chooses at runtime per submit.Datasets stored server-side or client-only? — Resolved. Client-only (parent → iframe via postMessage). CraftKit never sees dataset content. No
dataset_ref, noembedDatasettable, no permission flag.Partner-supplied PDFs: store URL or re-host? — Phase 1 stores URL only. Phase 3 adds opt-in
mirror: truefor partners that want CraftKit to keep the URL stable.Interception timeout window? — Default flow waits 500ms after emitting
craftkit.form.submitfor apreventDefaultclaim. 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.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 addformSpec.version.