<!-- doc:welcome -->
# Welcome

> **Documents, crafted.** Build a template once, expose it as a typed REST API, a signed inbound webhook, or an embeddable form, and render PDFs in milliseconds.

Craftkit is a developer toolkit for generating documents. You design a template visually with merge fields, then ship it through whichever surface fits the integration: a typed REST endpoint at `/v1/templates/<slug>/render`, an HMAC-signed inbound webhook URL, an embeddable builder for your customers, or a drop-in form for end-users to fill. Send the data, get a PDF back, with delivery webhooks, storage, version pinning, and dashboards out of the box.

## Quick Start

The fastest path to a rendered PDF is the REST API. Three steps: create a template in the dashboard, mint an API key, render against it.

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/templates/invoice/render \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"customer": {"name": "Acme Corp"}}}'
```

**Node.js**
```javascript
const res = await fetch('https://api.craftkit.dev/v1/templates/invoice/render', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ data: { customer: { name: 'Acme Corp' } } }),
});
const { id, pollUrl } = await res.json();
```

**Python**
```python
import os, requests

res = requests.post(
    "https://api.craftkit.dev/v1/templates/invoice/render",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
    json={"data": {"customer": {"name": "Acme Corp"}}},
)
job = res.json()
```

The full walkthrough lives in [Quickstart](/documentation/quickstart).

## What's in the docs

| Section | Covers |
|---|---|
| Get started | Quickstart and the mental model behind templates, versions, variables, and renders. |
| REST API | Authentication, the render endpoint, polling, the inbound webhook, and the error envelope. |
| Embed | Drop the Craftkit builder or form into your own SaaS — theming, JWT, postMessage, the host SDK, the variable catalog. |
| Architecture | How the pieces fit together: data model, render pipeline, tenancy. |

## Pick a path

- **Backend integrator** — start with the [Quickstart](/documentation/quickstart), then read [Authentication](/documentation/api/authentication) and the [Render API](/documentation/api/render-template).
- **Embedding the builder in a SaaS** — open the [Embed overview](/documentation/embed), then the [Embed quickstart](/documentation/embed/quickstart) and [Styling & themes](/documentation/embed/styling).
- **Adding a fill-in form for end-users** — see the [Embed quickstart](/documentation/embed/quickstart) (the form section) and the [Form-fill embeddable](/documentation/embed/form-route) reference.
- **Curious how it works** — read the [Architecture overview](/documentation/architecture/overview).

## Related

- [Quickstart](/documentation/quickstart)
- [Concepts](/documentation/concepts)
- [Authentication](/documentation/api/authentication)
- [Embed quickstart](/documentation/embed/quickstart)


---

<!-- doc:quickstart -->
# Quickstart

This walkthrough takes you from zero to a rendered PDF in five minutes. You'll create a project, design a template visually, mint an API key, hit the render endpoint, and download the result.

## Quick Start

End-to-end, in three calls: render, poll, download. The same operation in three languages:

**curl**
```bash
# 1. Enqueue a render
curl -X POST https://api.craftkit.dev/v1/templates/invoice/render \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"customer": {"name": "Acme Corp"}}}'

# 2. Poll until succeeded (returns downloadUrl when done)
curl https://api.craftkit.dev/v1/renders/<id> \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY"

# 3. Download the PDF
curl -L "<downloadUrl>" -o invoice.pdf
```

**Node.js**
```javascript
const headers = {
  Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
  'Content-Type': 'application/json',
};

const enqueue = await fetch('https://api.craftkit.dev/v1/templates/invoice/render', {
  method: 'POST',
  headers,
  body: JSON.stringify({ data: { customer: { name: 'Acme Corp' } } }),
});
const { id, pollUrl } = await enqueue.json();

let job;
do {
  await new Promise((r) => setTimeout(r, 250));
  job = await (await fetch(pollUrl, { headers })).json();
} while (job.status === 'queued' || job.status === 'rendering');

console.log('PDF ready at:', job.downloadUrl);
```

**Python**
```python
import os, time, requests

headers = {"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"}

enqueue = requests.post(
    "https://api.craftkit.dev/v1/templates/invoice/render",
    headers=headers,
    json={"data": {"customer": {"name": "Acme Corp"}}},
).json()

job = enqueue
while job["status"] in ("queued", "rendering"):
    time.sleep(0.25)
    job = requests.get(enqueue["pollUrl"], headers=headers).json()

print("PDF ready at:", job["downloadUrl"])
```

## Step 1 — Create a project

Sign in at [app.craftkit.dev](/dashboard) and create a project. A project is the unit that owns templates, API keys, webhooks, and rendered assets.

## Step 2 — Design a template

Inside the project, click **+ New template** and pick a builder mode:

| Mode | When to use it |
|---|---|
| Document canvas | Paginated A4/Letter with headers, footers, and signatures. Use for contracts, certificates, invoices. |
| Simple flow | A continuous document. Use for receipts, transactional emails, short one-pagers. |

Use the toolbar to insert a **Variable** block. Give it a key path like `customer.name` and mark it required. Click **Publish version** when you're happy.

## Step 3 — Mint an API key

Open the project's **API keys** tab and create one. The cleartext key is shown **once** — copy it immediately and store it as a server-side secret.

## Step 4 — Render

The Quick Start above shows the full enqueue → poll → download cycle. Most renders complete in **under 200ms**. If you'd rather hold the connection open instead of polling, set `options.sync: true` on the request.

## Tips

- Pin renders to a specific version with `options.versionNumber` for deterministic output across template edits.
- Use the dashboard's **Renders** tab to inspect every call: input data, duration, output asset, and which API key triggered it.
- For high volume, configure an outgoing webhook (project settings) so Craftkit posts to you on completion — no polling needed.

## Related

- [Authentication](/documentation/api/authentication) — how API keys work in practice
- [Concepts](/documentation/concepts) — templates, versions, manifests
- [POST /v1/templates/:slug/render](/documentation/api/render-template) — full request shape
- [Inbound webhook](/documentation/api/inbound-webhook) — let Stripe, Zapier, or Make trigger a render directly


---

<!-- doc:concepts -->
# Concepts

This page is the mental model behind Craftkit. Five primitives compose every workflow: templates, versions, variables, loops, and renders.

## Quick Start

If you only remember one thing, remember this hierarchy:

```
Project
 └── Template (slug: "invoice")
      └── Template version (immutable snapshot, "v3")
           ├── Variables (manifest)
           └── Loops (repeating blocks)
                └── Render (one execution against input data)
```

A `POST /v1/templates/invoice/render` walks the chain: latest published version of `invoice` → manifest → validate input → enqueue render row.

## Templates

A **template** is a visual blueprint with merge fields. It belongs to a project, has a slug (`invoice`, `contract`, ...), and accumulates versions over time.

You design templates in either the **Document canvas** (paginated, for contracts) or the **Simple flow** editor (continuous, for receipts).

## Versions

Every time you click **Publish version**, Craftkit snapshots the template's content, compiles it, and stores it as a new immutable `templateVersion`. The active version is what `/render` uses by default. Old versions stay on disk forever — past API calls keep working.

Pin a render to a specific version:

```json
{
  "data": { "...": "..." },
  "options": { "versionNumber": 3 }
}
```

## Variables

A **variable** is a single merge field with a key, a label, a data type, and a `required` flag. Keys can be dot-pathed (`customer.name`, `order.shipping.country`).

| Data type | Description |
|---|---|
| `text` | Single-line string |
| `longtext` | Multi-line string |
| `number` | Finite numeric, optional integer flag |
| `currency` | Numeric with currency formatting (`format: "money:EUR"`) |
| `date` | ISO date |
| `datetime` | ISO datetime |
| `boolean` | True/false checkbox |
| `image` | Image asset (URL or upload) |
| `url` | Hyperlink |
| `email` | Email address |
| `select` | Fixed choice from a declared `options` list |

A `select` variable carries an **`options`** array of `{ value, label }` choices — `value` is stored and validated, `label` is display text. A `select` requires a non-empty `options` list at publish time (a `select` with no options is rejected); option `value`s must be unique, and a `defaultValue`, if set, must be a string matching one of them. At render time only a declared `value` is accepted (anything else fails with `invalid_input_data`), and the auto-generated JSON Schema emits the field as a string `enum` of the option values. `options` is optional and only meaningful for `select`, so clients that ignore it are unaffected.

```json
{
  "key": "bunker_cost_party",
  "dataType": "select",
  "required": false,
  "label": "Bunker cost party",
  "options": [
    { "value": "SHIPOWNER", "label": "Shipowner" },
    { "value": "CHARTERER", "label": "Charterer" }
  ]
}
```

When a version is published, Craftkit walks the template, collects every variable and loop, and stores a **variable manifest** on the version. That manifest is the contract behind:

- The Zod validator that gates incoming render requests.
- The auto-generated JSON Schema shown in the dashboard.
- The cURL snippet on the template detail page.
- The form fields rendered by the embeddable form route.

## Loops

A **loop** is a repeating block (Handlebars-style `{{#each items}}...{{/each}}`) keyed on an array variable. Each loop has an item shape — its own list of variables. Loops are how you express invoice line items, package contents, attendee lists.

## Renders

A **render** is one execution of one template version against one set of input data. Each render lives as a row in Postgres with:

| Field | Description |
|---|---|
| `status` | `queued` → `rendering` → `succeeded` \| `failed` \| `cancelled` |
| `data` | The input payload that was validated against the manifest |
| `downloadUrl` | The output asset URL once the render reaches a terminal state |
| `durationMs` | Wall-clock time from enqueue to completion |
| `source` | What triggered the render: `api`, `form`, `partner_supplied`, or `dashboard` |
| `apiKeyId` | The API key (or embed session) that initiated the call |

The dashboard's **Renders** tab is just a window onto this table.

## Glossary

| Term | Meaning |
|---|---|
| Template | A visual blueprint with merge fields, scoped to a project |
| Template version | An immutable snapshot of a template's content + manifest |
| Variable | A single merge field (key + type + required) |
| Loop | A repeating block keyed on an array variable |
| Manifest | The list of variables + loops extracted from a version |
| Render | One execution of one template version against input data |
| API key | A bearer token authenticating one project's API calls |
| Inbound webhook | A signed URL that triggers a render from any external system |
| Embed session | A short-lived JWT that scopes an iframe mount to one tenant + actor |

## Related

- [Quickstart](/documentation/quickstart) — see the primitives in action
- [POST /v1/templates/:slug/render](/documentation/api/render-template) — the request that uses the manifest
- [Architecture: data model](/documentation/architecture/data-model) — the Postgres schema for these primitives


---

<!-- doc:integration-guide -->
# Integration guide

A step-by-step implementation guide that takes you from a new Craftkit account to a fully working production integration — covering the REST API, the builder embed, and the form-fill embed.

> **Pick your path:** If you only need programmatic PDF rendering (no embedded editor), complete Phase 1 and Phase 2 then stop. Add Phase 3 when your customers need to design their own templates. Add Phase 4 when end-users need to fill and generate documents themselves.

---

## What you'll build

| Phase | What it unlocks |
|---|---|
| Phase 1 — Setup | Account, project, API key, local dev environment |
| Phase 2 — API rendering | Your backend calls Craftkit, gets a PDF |
| Phase 3 — Builder embed | Your customers design templates inside your app |
| Phase 4 — Form embed | Your end-users fill templates and produce documents |
| Phase 5 — Production | Hardening, key rotation, monitoring, error handling |

---

## Phase 1 — Setup

### 1.1 Create your project

Sign in → Dashboard → **New project** → give it a name. One project = one namespace for templates, API keys, and embed settings.

### 1.2 Design a template

Dashboard → your project → **Templates** → **New template**. Design it in the visual editor:
- Add text, images, and layout blocks
- Insert variables with `{{ }}` — e.g. `{{customer.name}}`, `{{invoice.total}}`
- Publish the template when ready

Note the **template slug** from the URL (`/templates/<slug>/edit`) — you'll use it in API calls.

### 1.3 Create an API key

Dashboard → Project → **API keys** → **Create key** → copy it immediately (shown once).

> **Environment rule:** API keys are stored as SHA-256 hashes in the database they were created in. A key minted on localhost only works against `http://localhost:3000`. A production key only works against `https://api.craftkit.dev`. Always create keys in the target environment's dashboard.

### 1.4 Configure your local environment

```bash
# .env.local
CRAFTKIT_API_KEY=ck_live_...   # never commit this
CRAFTKIT_API_URL=https://api.craftkit.dev
```

Verify connectivity:
```bash
curl https://api.craftkit.dev/health
# → { "status": "ok" }
```

---

## Phase 2 — API rendering

This is the simplest integration: your backend feeds data → Craftkit returns a PDF. No iframe, no embed, no browser involved.

### 2.1 Enqueue a render

```bash
curl -X POST https://api.craftkit.dev/v1/templates/charter-contract/render \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "customer.name": "Ada Lovelace",
      "booking.date": "2026-06-15",
      "booking.vessel": "SV Horizon"
    },
    "jobId": "booking-12345"
  }'
```

Response:
```json
{
  "id": "render_01...",
  "status": "queued",
  "pollUrl": "https://api.craftkit.dev/v1/renders/render_01..."
}
```

`jobId` is optional but recommended — it makes the render idempotent (retrying with the same `jobId` returns the existing render instead of creating a new one).

### 2.2 Poll until complete

```javascript
async function waitForRender(renderId, timeoutMs = 30_000) {
  const deadline = Date.now() + timeoutMs;
  let delay = 250;
  while (Date.now() < deadline) {
    const r = await fetch(`https://api.craftkit.dev/v1/renders/${renderId}`, {
      headers: { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` },
    });
    const render = await r.json();
    if (render.status === 'succeeded') return render.downloadUrl;
    if (render.status === 'failed') {
      throw new Error(`Render failed: ${render.errorMessage}`);
    }
    await new Promise(res => setTimeout(res, delay));
    delay = Math.min(delay * 2, 5_000);
  }
  throw new Error('Render timed out');
}

const pdfUrl = await waitForRender(renderId);
```

### 2.3 Receive via webhook (alternative to polling)

Instead of polling, configure a webhook on the template so Craftkit pushes the result to your server when done.

Dashboard → Template → **Webhooks** → **Add webhook URL** → enter your endpoint.

```javascript
// POST https://yourapp.com/api/craftkit/webhook
app.post('/api/craftkit/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig  = req.headers['x-craftkit-signature'];
  const hmac = createHmac('sha256', process.env.CK_WEBHOOK_SECRET)
                 .update(req.body).digest('hex');
  if (hmac !== sig) return res.status(401).end();  // x-craftkit-signature is raw hex, no prefix

  const event = JSON.parse(req.body);
  if (event.event === 'render.succeeded') {
    storeDownloadUrl(event.renderId, event.downloadUrl);
  }
  res.json({ received: true });
});
```

### 2.4 Handle errors

```javascript
const res = await fetch(url, options);
if (!res.ok) {
  const { error } = await res.json();
  switch (error.code) {
    case 'template_not_found':
      throw new Error('Wrong slug or wrong project API key');
    case 'no_published_version':
      throw new Error('Publish a template version in the dashboard first');
    case 'invalid_input_data':
      console.error('Field errors:', error.issues?.fieldErrors);
      throw new Error('Data does not match the template manifest');
    case 'rate_limited':
      await backoff();
      return retry();
    default:
      throw new Error(`${error.code}: ${error.message}`);
  }
}
```

See [Errors](/documentation/api/errors) for the full code list and retry semantics.

---

## Phase 3 — Builder embed

Let your customers design their own templates directly inside your app — without leaving to a separate tool.

### 3.1 Enable embed mode

Dashboard → Project → **Embed → Overview** → **Enable embed mode**.

Craftkit generates a publishable key (`ck_pk_live_...`) and an Ed25519 signing key. Add your production domain to the allowed origins list:

| Pattern | Example |
|---|---|
| Exact match | `https://app.acme.com` |
| Wildcard subdomain | `https://*.acme.com` |

### 3.2 Publish a variable catalog (optional but recommended)

A catalog injects your data model into the builder's variable picker. Ship it from your CI/CD pipeline so it stays in sync with your database schema.

```bash
curl -X POST https://api.craftkit.dev/v1/embed/catalogs \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acme-v1",
    "catalog": {
      "allowCustom": false,
      "namespaces": [
        {
          "key": "customer", "label": "Customer",
          "fields": [
            { "key": "customer.name",    "label": "Name",    "dataType": "text"  },
            { "key": "customer.email",   "label": "Email",   "dataType": "email" },
            { "key": "customer.company", "label": "Company", "dataType": "text"  }
          ]
        },
        {
          "key": "booking", "label": "Booking",
          "fields": [
            { "key": "booking.date",     "label": "Date",     "dataType": "date"   },
            { "key": "booking.vessel",   "label": "Vessel",   "dataType": "text"   },
            { "key": "booking.total",    "label": "Total",    "dataType": "currency" }
          ]
        }
      ],
      "loops": []
    }
  }'
# → { "id": "cat_01...", "name": "acme-v1", "version": 1 }
```

Store the catalog `id`. Use it in every session mint.

### 3.3 Add a session-mint endpoint to your backend

```javascript
// POST /api/craftkit/builder-session
app.post('/api/craftkit/builder-session', requireAuth, async (req, res) => {
  const { templateExternalId } = req.body;  // null for new templates

  const r = await fetch('https://api.craftkit.dev/v1/embed/sessions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CK_SECRET}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      tenant: {
        externalId:  req.user.orgId,
        displayName: req.user.orgName,
      },
      actor: {
        externalId:  req.user.id,
        email:       req.user.email,
        displayName: req.user.name,
      },
      scope: {
        mode: templateExternalId ? 'edit' : 'create',
        templateExternalId,
      },
      permissions: {
        publish:              true,
        saveDraft:            true,
        createCustomVariables: false,  // lock to catalog
        viewVersionHistory:   true,
      },
      catalogId: process.env.CK_CATALOG_ID,
      callbacks: {
        onPublished: `${process.env.APP_URL}/api/craftkit/events`,
      },
    }),
  });

  if (!r.ok) {
    const { error } = await r.json();
    return res.status(502).json({ error });
  }

  const { session_token, renew_token, expires_at } = await r.json();

  // Store renew_token server-side for later refresh
  await storeRenewToken(req.user.id, renew_token);

  res.json({ sessionToken: session_token, expiresAt: expires_at });
});
```

### 3.4 Add a session-refresh endpoint

```javascript
// POST /api/craftkit/refresh
app.post('/api/craftkit/refresh', requireAuth, async (req, res) => {
  const renewToken = await getRenewToken(req.user.id);

  const r = await fetch('https://api.craftkit.dev/v1/embed/sessions/refresh', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CK_SECRET}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ renewToken }),
  });

  const { session_token, renew_token } = await r.json();
  await storeRenewToken(req.user.id, renew_token);  // rotate — it's single-use
  res.json({ session_token });
});
```

### 3.5 Mount the builder in your frontend

```javascript
import { Craftkit } from '@craftkit/embed';

const ck = Craftkit.init({ publishableKey: 'ck_pk_live_...' });

async function openTemplateEditor(templateExternalId = null) {
  // 1. Get a session token from your backend
  const { sessionToken } = await fetch('/api/craftkit/builder-session', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ templateExternalId }),
  }).then(r => r.json());

  // 2. Mount the builder
  const builder = ck.mountBuilder({
    container: '#editor-container',
    sessionToken,
    autoResize: true,
    refresh: async () => {
      const r = await fetch('/api/craftkit/refresh', { method: 'POST' });
      return (await r.json()).session_token;
    },
  });

  // 3. Handle the result
  builder.on('template.published', async ({ templateId, version, manifest }) => {
    // Save the mapping to your database
    await saveTemplate({ templateExternalId, craftkitId: templateId, version });
    builder.destroy();
    showToast(`Template published (v${version})`);
  });

  builder.on('close.requested', () => builder.destroy());
  builder.on('error', (err) => {
    if (!err.recoverable) {
      builder.destroy();
      showError(err.message);
    }
  });
}
```

### 3.6 Handle the webhook

```javascript
// POST /api/craftkit/events
app.post('/api/craftkit/events', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig  = req.headers['x-craftkit-signature'];
  const hmac = createHmac('sha256', process.env.CK_WEBHOOK_SECRET)
                 .update(req.body).digest('hex');
  if (hmac !== sig) return res.status(401).end();  // x-craftkit-signature is raw hex, no prefix

  const event = JSON.parse(req.body);

  if (event.type === 'template.published') {
    await db.upsert('templates', {
      tenantId:    event.tenantExternalId,
      externalId:  event.templateExternalId,
      craftkitId:  event.templateId,
      version:     event.version,
      variables:   event.manifest.fields,
    });
  }

  res.json({ received: true });
});
```

> **Tip:** Always write to your database from the webhook, not just the postMessage event. The postMessage fires faster (great for UI), but the webhook is durable — it fires even if the tab was closed before the editor finished.

---

## Phase 4 — Form embed

Let end-users fill a published template's variables and produce a document, directly inside your app — no coding required on their part.

### 4.1 Prerequisites

- You need a **published template** (from Phase 3 or created in the dashboard).
- The template's variables must match your data model (use the catalog from Phase 3).
- The session must use `scope.mode: 'fill'` and include `scope.templateId`.

### 4.2 Add a form-session endpoint to your backend

```javascript
// POST /api/craftkit/form-session
app.post('/api/craftkit/form-session', requireAuth, async (req, res) => {
  const { craftkitTemplateId } = req.body;  // from your DB (stored in Phase 3)

  const r = await fetch('https://api.craftkit.dev/v1/embed/sessions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CK_SECRET}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      tenant: { externalId: req.user.orgId, displayName: req.user.orgName },
      actor:  { externalId: req.user.id,    email: req.user.email },
      scope: {
        mode:       'fill',
        template_id: craftkitTemplateId,
      },
      permissions: { submit_form: true },
    }),
  });

  const { session_token, renew_token } = await r.json();
  await storeRenewToken(req.user.id, renew_token);
  res.json({ session_token });
});
```

### 4.3 Mount the form in your frontend

```javascript
import { Craftkit } from '@craftkit/embed';

const ck = Craftkit.init({ publishableKey: 'ck_pk_live_...' });

async function openDocumentForm(craftkitTemplateId) {
  const { session_token } = await fetch('/api/craftkit/form-session', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ craftkitTemplateId }),
  }).then(r => r.json());

  const form = ck.mountForm({
    container: '#form-container',
    sessionToken: session_token,
    autoResize: true,
    refresh: async () => {
      const r = await fetch('/api/craftkit/refresh', { method: 'POST' });
      return (await r.json()).session_token;
    },
  });

  // Optional: pre-fill form fields from your app's data
  form.on('ready', () => {
    form.setValues({
      'customer.name':  currentUser.name,
      'customer.email': currentUser.email,
    });
  });

  // Document produced
  form.on('form.completed', ({ renderId, downloadUrl }) => {
    storeDocument(renderId, downloadUrl);
    showDownloadLink(downloadUrl);
  });

  form.on('close.requested', () => form.destroy());
}
```

### 4.4 Optional: dataset prefill

Let users pick a record from your app's data (e.g. "pick a booking") and have it auto-fill the form fields.

```javascript
form.on('ready', () => {
  form.setDatasets({
    bookings: {
      label: 'Pick a booking',
      items: myBookings.map(b => ({
        id:     b.id,
        label:  `${b.vessel} — ${b.date}`,
        values: {
          'booking.date':   b.date,
          'booking.vessel': b.vessel,
          'booking.total':  b.total,
        },
      })),
    },
  });
});
```

Dataset content stays client-side — Craftkit only logs the selected item's `id` for audit.

### 4.5 Optional: intercept submit for custom logic

Intercept the submit to add signing, custom rendering, or post-processing before the PDF is displayed.

```javascript
form.on('submit', async (e) => {
  if (!needsSigning(e.data)) return;    // skip — let default render proceed

  e.preventDefault();                   // claim the submit (must be within 500ms)
  try {
    const signed = await mySigningService(e.data);
    const pdf    = await myRenderPipeline(signed);
    await e.complete({ pdfUrl: pdf.url });
    // iframe displays the PDF; emits form.completed { source: 'partner_supplied' }
  } catch (err) {
    await e.fail({ message: err.message });
  }
});
```

See [Form-fill embeddable](/documentation/embed/form-route) for the full form reference.

---

## Phase 5 — Production hardening

### Checklist

**API keys**
- [ ] One key per integration (rendering, embed, inbound webhooks — each separate)
- [ ] Keys stored in environment variables, never in code or logs
- [ ] Test keys (`ck_test_...`) for staging, live keys (`ck_live_...`) for production — never mix
- [ ] Rotate keys quarterly: mint new → deploy → revoke old

**Embed setup**
- [ ] Only production origins in the allowed-origins list
- [ ] Catalog deployed from CI/CD and `catalogId` stored in your env vars
- [ ] `renew_token` stored server-side and rotated on every refresh
- [ ] `refresh` callback wired up in the SDK so sessions don't expire mid-edit

**Error handling**
- [ ] All API calls wrapped with error handling that switches on `error.code`
- [ ] Retry logic for `rate_limited` and `5xx` (exponential backoff, max 5 attempts)
- [ ] `error` event handled in the builder and form with fallback UI for `recoverable: false`

**Webhooks**
- [ ] HMAC signature verified on every webhook before processing
- [ ] Webhook endpoint responds within 10s (enqueue heavy work, don't block)
- [ ] Idempotency: use `jobId` for renders, check for duplicate `templateId + version` in your DB

**Monitoring**
- [ ] Alert on sustained `5xx` rate from Craftkit endpoints
- [ ] Track `session.expired` events — indicates `refresh` is failing
- [ ] Log `error.code` from the embed error event to a monitoring system

### Key rotation procedure

```bash
# 1. Mint a new key in the dashboard
# 2. Update your environment variable
export CRAFTKIT_API_KEY=ck_live_<new>

# 3. Deploy (traffic shifts to new key)
vercel deploy --prod

# 4. Verify traffic on new key (check your metrics / Craftkit dashboard)

# 5. Revoke the old key in the dashboard
# Revocation is immediate — no grace period.
```

### Error code quick reference

| Code | Phase | Fix |
|---|---|---|
| `missing_authorization` | Any | Add `Authorization: Bearer ...` header |
| `invalid_credentials` | Any | Key from wrong environment or embed not enabled — see [Authentication](/documentation/api/authentication#troubleshooting-invalid_credentials) |
| `template_not_found` | Phase 2 | Wrong slug or wrong project's key |
| `no_published_version` | Phase 2, 4 | Publish a template version first |
| `invalid_input_data` | Phase 2 | Check `error.issues.fieldErrors` against the template manifest |
| `rate_limited` | Any | Back off and retry |
| `session_invalid` | Phase 3, 4 | Stale session — mint a new one |
| `origin_not_allowed` | Phase 3, 4 | Add the origin in Dashboard → Embed → Origins |
| `internal_error` | Any | Retry with backoff; check status page |

---

## Related

- [Quickstart](/documentation/quickstart) — render your first PDF in five minutes
- [Builder embed](/documentation/embed/builder) — full builder reference
- [Form-fill embeddable](/documentation/embed/form-route) — full form reference
- [Embed quickstart](/documentation/embed/quickstart) — minimal embed setup
- [Variable catalog](/documentation/embed/variable-catalog) — field schema reference
- [POST /v1/embed/catalogs](/documentation/api/embed-catalogs) — catalog API
- [Authentication](/documentation/api/authentication) — key management and troubleshooting
- [Errors](/documentation/api/errors) — full error envelope and retry semantics


---

<!-- doc:ai-setup -->
# AI project setup — one-prompt Craftkit integration

Copy the prompt below and paste it directly into Claude, ChatGPT, Cursor, or any AI
coding assistant. The AI will ask you a few questions and then generate a complete,
production-ready Craftkit integration tailored to your project.

---

## The prompt

> You are setting up a complete Craftkit document-generation integration.
> Craftkit (https://www.craftkit.dev) lets SaaS apps embed a template builder and/or a
> document form-fill widget. It also exposes a REST API for programmatic PDF rendering.
>
> Ask me the following questions ONE AT A TIME before writing any code:
>
> 1. **Stack**: What framework and language is your backend? (Next.js App Router, Express, FastAPI, Rails, Laravel, etc.)
> 2. **Frontend**: What framework is your frontend? (React, Vue, Svelte, vanilla JS, etc.)
> 3. **Auth**: What auth system do you use? (Clerk, NextAuth, custom JWT, session cookies, etc.) — I need to know how to protect the Craftkit proxy routes.
> 4. **Tenancy**: Is this single-tenant (one org) or multi-tenant (many orgs, each needing isolated templates)?
> 5. **Use case**: Which embed surfaces do you need?
>    - (A) **Builder only** — your customers design their own templates
>    - (B) **Form-fill only** — your users fill and generate documents from pre-built templates
>    - (C) **Both** — customers design templates; users fill them
>    - (D) **API-only** — your backend triggers renders programmatically (no embed)
> 6. **Variables**: Describe your data model briefly — what fields will populate the documents?
>    (e.g. "customer name/email, booking date/vessel, invoice total")
>
> Once I answer all six questions, generate the following **complete, working code** with no placeholders:
>
> ### A. Environment variables
> List every env var needed with a comment explaining each one.
>
> ### B. Backend proxy routes
> For Next.js App Router: generate the full `app/api/craftkit/` directory tree with all
> route files. For other frameworks: generate equivalent middleware/controllers.
> Always use the **admin provision** pattern for multi-tenant:
> - `lib/credentials.ts` — `getOrgApiKey(orgId)` caches per-org keys via `POST /v1/admin/provision`
> - `session/route.ts` — mint sessions; resolve org key; support modes: create/edit/fill/view
> - `refresh/route.ts` — refresh sessions; **must pass `orgId` and use `getOrgApiKey(orgId)`**,
>   NOT a single fixed `CRAFTKIT_API_KEY` (that breaks multi-tenant refresh)
> - `templates/route.ts` — list published templates for an org
> - `renders/route.ts` — list renders for an org
> - `renders/[id]/download/route.ts` — proxy PDF download
>
> ### C. Frontend embed component
> A complete, reusable React/Vue/vanilla component that:
> - Mints a session on mount (calls the backend session route)
> - Renders the Craftkit iframe using the returned `iframe_url`
> - Handles `session.expiring` by calling the refresh route with `orgId`
> - Handles `session.expired` by re-minting
> - Emits `onPublished` when a template is published (saves `craftkitTemplateId` to your DB)
> - Emits `onCompleted` when a form-fill document is ready
> - Shows a loading state while minting and an error state on failure
>
> ### D. Variable catalog
> Generate a `POST /v1/embed/catalogs` call (curl + equivalent code) with my data model
> mapped to Craftkit field types (`text`, `number`, `currency`, `date`, `email`, `url`,
> `image`, `boolean`, `longtext`). Mark fields that should be required in the form.
>
> ### E. Example page
> One example page/route that uses the embed component — create mode for a new template
> or fill mode for a document.
>
> ### F. Checklist
> A copy-paste checklist of everything I need to do in the Craftkit dashboard before this
> code will work:
> - [ ] Create account and project
> - [ ] Enable embed mode (Dashboard → Project → Embed → Overview)
> - [ ] Add allowed origins for the embed iframe
> - [ ] Note the CRAFTKIT_ADMIN_KEY from project settings
> - [ ] (if catalog) Publish the variable catalog and note its name
>
> **Key rules the AI must follow when generating code:**
> - Never hardcode API keys or secrets — always use env vars
> - The refresh route MUST use `getOrgApiKey(orgId)`, not a fixed `CRAFTKIT_API_KEY`
> - The `templateExternalId` passed to session minting must be the Craftkit UUID
>   (from the `template.published` event), NOT an internal MongoDB ObjectId or integer ID
> - The `iframe_url` from the session response must be used verbatim — never construct
>   the iframe URL manually (path differs between fill and builder modes)
> - Validate `e.origin` before acting on postMessage events
> - Always mint a fresh session for each iframe mount — never cache `iframe_url`

---

## What the AI will generate

Depending on your answers, the AI generates a complete, drop-in integration:

| Answer | Generated output |
|---|---|
| Next.js + Clerk + multi-tenant + builder + form | Full `app/api/craftkit/` directory, `CraftkitEmbed` component, Clerk `auth()` guard, Zustand/React Query hooks |
| Express + JWT + single-tenant + API-only | Middleware, render controller, webhook handler, polling utility |
| Next.js + NextAuth + single-tenant + form-fill | Slim proxy (no provision), form-fill component, catalog |

---

## Quick reference — key decisions

### Multi-tenant vs single-tenant

| | Single-tenant | Multi-tenant |
|---|---|---|
| Env vars | `CRAFTKIT_API_KEY` (one fixed key) | `CRAFTKIT_ADMIN_KEY` (provision key) |
| Session mint | Use `CRAFTKIT_API_KEY` directly | Call `getOrgApiKey(orgId)` |
| Session refresh | Use `CRAFTKIT_API_KEY` directly | Call `getOrgApiKey(orgId)` — **MUST match session's org** |
| Org isolation | Shared project | One Craftkit project per org, auto-provisioned |

### Mode cheat sheet

| Mode | iframe path | Permissions required |
|---|---|---|
| `create` | `/embed/builder` | `publish: true, saveDraft: true` (defaults) |
| `edit` | `/embed/builder` | `publish: true, saveDraft: true` (defaults) |
| `view` | `/embed/builder` | `publish: false, saveDraft: false` |
| `fill` | `/embed/form` | `submitForm: true` ← **must set explicitly** |

### Critical pitfalls

1. **Never use a MongoDB ObjectId as `templateExternalId`** — Craftkit requires UUID format.
   Store the UUID from the `template.published` event and use that.

2. **Never cache `iframe_url` across page navigations** — each mount needs a fresh mint.

3. **Never use a fixed `CRAFTKIT_API_KEY` for refresh in multi-tenant** — the session's
   `partnerId` must match the key used for refresh. Use `getOrgApiKey(orgId)`.

4. **`submitForm` defaults to `false`** — fill sessions must explicitly set it to `true`.

5. **API keys are environment-specific** — a key created in local dev does not exist in
   production. Always create keys in the target environment's Craftkit dashboard.

---

## Related

- [Integration guide](/documentation/integration-guide) — full phase-by-phase walkthrough
- [Multi-tenant embed](/documentation/embed/multi-tenant) — admin provision deep dive
- [Embed quickstart](/documentation/embed/quickstart) — minimal setup
- [Variable catalog](/documentation/embed/variable-catalog) — field schema reference
- [Client integration guide](../embed/13-client-integration-guide.md) — pitfall checklist


---

<!-- doc:api/authentication -->
# Authentication

All requests to `/v1/*` carry a project API key in the `Authorization` header. Keys are scoped to one project and can be created or revoked from the dashboard. This page covers the format, how to create and rotate keys, scope, and the errors you'll see when something is off.

## Quick Start

Three languages, the same authenticated request.

**curl**
```bash
curl https://api.craftkit.dev/v1/renders/0193c2c3 \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY"
```

**Node.js**
```javascript
const res = await fetch('https://api.craftkit.dev/v1/renders/0193c2c3', {
  headers: {
    Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
  },
});
```

**Python**
```python
import os, requests

res = requests.get(
    "https://api.craftkit.dev/v1/renders/0193c2c3",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
)
```

## Header format

```http
Authorization: Bearer ck_live_<random>
```

| Prefix | Environment |
|---|---|
| `ck_live_` | Production renders, billable, hits the live worker pool |
| `ck_test_` | Test renders, free, watermarked output |

## Creating a key

Dashboard → Project → **API keys** → enter a name → **Create key**. The cleartext key is returned **once** — copy it immediately. We only store the SHA-256 hash, so a lost key cannot be recovered.

Each project can have many keys, each independently revocable. Use one key per integration so you can rotate without coordinating an outage across services.

## Revoking a key

Same screen → **Revoke**. Subsequent calls return `401 invalid_credentials` immediately (no grace window). Issue a replacement first, deploy it, then revoke.

## Scope

API keys are scoped to one project. They **can**:

- POST a render against any template in that project.
- Read renders for that project.
- Trigger inbound webhooks for templates in that project (the inbound token authenticates instead, but the key still owns the resulting render row).

They **cannot**:

- Modify templates, projects, or webhook configs (use the dashboard).
- Read other projects' data.
- Mint embed sessions for partner integrations (use the embed publishable + signing key pair instead).

## Errors

| HTTP | Code | When | Fix |
|---|---|---|---|
| 401 | `missing_authorization` | No `Authorization: Bearer ...` header | Add the header |
| 401 | `invalid_credentials` | Key is unknown or revoked | Mint a new key |
| 403 | `partner_suspended` | Project's embed partnership is suspended | Contact support |

## Best practices

- **Never embed keys in client code.** Browser bundles, mobile apps, and public repositories all leak. Keep keys on a server.
- **Use environment-specific keys.** Separate `ck_test_` for staging and `ck_live_` for production. Never share a key across environments.
- **Rotate quarterly.** Mint a new key, deploy it, revoke the old one once metrics confirm zero traffic on the previous key.
- **One key per workload.** Background jobs, the synchronous API path, and the inbound webhook handler each get their own key. Revoking one doesn't disrupt the others.

## Troubleshooting `invalid_credentials`

**The most common cause: the key was created in a different environment.**

API keys are stored as SHA-256 hashes in the database for the environment where they were created. A key you minted in your local dev environment does not exist in production, and vice versa. If you see `invalid_credentials` after deploying, the fix is to mint a new key inside the target environment's dashboard.

```
Local dev key  → only works against http://localhost:3000
Production key → only works against https://api.craftkit.dev
```

**Second most common cause (embed sessions): embed is not enabled for the project.**

`POST /v1/embed/sessions` and `POST /v1/embed/catalogs` require the API key's project to have embed enabled. An otherwise valid key returns `invalid_credentials` on these endpoints if the project has no embed partner record.

Fix: Dashboard → Project → **Embed → Overview** → **Enable embed mode**.

**Checklist**

| Symptom | Check |
|---|---|
| Valid key works locally, fails in production | Re-mint the key in the production dashboard |
| Key works on `/v1/renders/*` but not `/v1/embed/*` | Enable embed mode for the project |
| Key was working, now suddenly fails | Check if it was revoked (Revoke column in API keys tab) |
| `missing_authorization` instead of `invalid_credentials` | The `Authorization: Bearer ...` header is missing entirely |

## Related

- [POST /v1/templates/:slug/render](/documentation/api/render-template) — the most common authenticated call
- [Errors](/documentation/api/errors) — the full envelope shape
- [Inbound webhook](/documentation/api/inbound-webhook) — alternative auth via per-template token


---

<!-- doc:api/render-template -->
# POST /v1/templates/:slug/render

Enqueue a render against a template. Returns `202 Accepted` with a poll URL. The variable data must match the template version's manifest, validated server-side with Zod.

```http
POST /v1/templates/:slug/render
```

`:slug` is the template's slug within the authenticated project.

## Quick Start

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/templates/invoice/render \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "customer": { "name": "Acme Corp" },
      "items": [{ "name": "Widget", "qty": 5, "price": 9.99 }],
      "total": 49.95
    }
  }'
```

**Node.js**
```javascript
const res = await fetch('https://api.craftkit.dev/v1/templates/invoice/render', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    data: {
      customer: { name: 'Acme Corp' },
      items: [{ name: 'Widget', qty: 5, price: 9.99 }],
      total: 49.95,
    },
  }),
});
const { id, pollUrl } = await res.json();
```

**Python**
```python
import os, requests

res = requests.post(
    "https://api.craftkit.dev/v1/templates/invoice/render",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
    json={
        "data": {
            "customer": {"name": "Acme Corp"},
            "items": [{"name": "Widget", "qty": 5, "price": 9.99}],
            "total": 49.95,
        }
    },
)
job = res.json()
```

## Request body

```json
{
  "data": {
    "customer": { "name": "Acme Corp" },
    "items": [{ "name": "Widget", "qty": 5, "price": 9.99 }],
    "total": 49.95
  },
  "options": {
    "versionNumber": 3,
    "sync": false,
    "filename": "invoice-2026-005.pdf"
  }
}
```

| Field | Type | Description | Default |
|---|---|---|---|
| `data` | object | Variable values keyed by manifest key paths. Required. Scalars nest by dot-path; loops are keyed by their dot-free top-level key. Keys absent from the manifest are stripped. | — |
| `jobId` | string | Optional idempotency key. Retrying with the same value returns the original render instead of creating a duplicate. The `Idempotency-Key` request header takes precedence. | — |
| `options.versionNumber` | integer | Pin to a specific published version. | Current published version |
| `options.sync` | boolean | Accepted by the schema but **not honored** — every render is async. Poll the result or use a webhook. | `false` |
| `options.filename` | string | Override the asset filename. | Template default |

> **Idempotency.** Send an `Idempotency-Key` header (preferred) or a body `jobId` so a retried submit dedupes to the original render. The header wins when both are present; the response is `200` on an idempotent replay vs `202` for a newly-queued render.

## Response — `202 Accepted`

```json
{
  "id": "0193c2c3-...",
  "status": "queued",
  "pollUrl": "https://api.craftkit.dev/v1/renders/0193c2c3-...",
  "downloadUrl": null,
  "errorMessage": null,
  "createdAt": "2026-05-03T10:14:00.000Z"
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | Render id (UUIDv7). |
| `status` | string | `queued` initially. Progresses to `rendering` then `succeeded` \| `failed`. |
| `pollUrl` | string | Hit this with the same bearer token to poll status. |
| `downloadUrl` | string \| null | Populated on success. A permanent public URL (no presigning, no TTL); `null` until the render succeeds. |
| `errorMessage` | string \| null | Populated on failure. |
| `createdAt` | string | ISO-8601 timestamp. |

Poll `pollUrl` until `status` is `succeeded` or `failed`. See [GET /v1/renders/:id](/documentation/api/render-status) for polling cadence and the outgoing webhook alternative.

## Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `invalid_json` | Body wasn't valid JSON | Check `Content-Type` and JSON.stringify |
| 400 | `invalid_request` | Top-level shape didn't match `{ data, options? }` | See request body table above |
| 400 | `invalid_input_data` | Data didn't match the version's manifest | Inspect `issues.fieldErrors` for offending keys |
| 404 | `template_not_found` | No template with that slug in this project | Check the slug and the API key's project scope |
| 404 | `version_not_found` | `options.versionNumber` doesn't exist | Omit it or pick a real version |
| 409 | `no_published_version` | Template has no published version yet | Publish a version in the dashboard |
| 429 | `rate_limited` | Project quota exceeded | Back off with jitter |

See [Errors](/documentation/api/errors) for the envelope shape and full code list.

## Tips

- **Idempotency:** safe to retry on 5xx and network errors — send the same `Idempotency-Key` header (or body `jobId`) so the retry returns the original render instead of creating a duplicate. Use a stable business key (e.g. `invoice-{orderId}`), not a per-attempt random value. A retry after a failure returns the failed render; use a new key to force a fresh attempt.
- **No sync mode:** `options.sync` is not honored — always poll `pollUrl` or configure an outgoing webhook. There is no long-poll.
- **Validation introspection:** the dashboard's template detail page exposes the auto-generated JSON Schema and a working cURL snippet that matches the live manifest.

## Related

- [GET /v1/renders/:id](/documentation/api/render-status) — poll the render to completion
- [Inbound webhook](/documentation/api/inbound-webhook) — same pipeline, signed-URL trigger
- [Errors](/documentation/api/errors) — error envelope and retry semantics
- [Authentication](/documentation/api/authentication) — bearer token format


---

<!-- doc:api/render-status -->
# GET /v1/renders/:id

Get the current status of a render. Most renders complete in under 200ms — this endpoint is what you poll between enqueue and download. For high volume, prefer the outgoing webhook.

```http
GET /v1/renders/:id
```

## Quick Start

**curl**
```bash
curl https://api.craftkit.dev/v1/renders/0193c2c3 \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY"
```

**Node.js**
```javascript
async function pollUntilDone(pollUrl) {
  const headers = { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` };
  let delay = 200;
  for (let i = 0; i < 20; i++) {
    const job = await (await fetch(pollUrl, { headers })).json();
    if (job.status === 'succeeded' || job.status === 'failed') return job;
    await new Promise((r) => setTimeout(r, delay));
    delay = Math.min(delay * 1.5, 1000);
  }
  throw new Error('render timed out');
}
```

**Python**
```python
import os, time, requests

def poll_until_done(poll_url):
    headers = {"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"}
    delay = 0.2
    for _ in range(20):
        job = requests.get(poll_url, headers=headers).json()
        if job["status"] in ("succeeded", "failed"):
            return job
        time.sleep(delay)
        delay = min(delay * 1.5, 1.0)
    raise TimeoutError("render timed out")
```

## Response

```json
{
  "id": "0193c2c3-...",
  "status": "succeeded",
  "pollUrl": "https://api.craftkit.dev/v1/renders/0193c2c3-...",
  "downloadUrl": "https://cdn.craftkit.dev/.../0193c2c3.pdf",
  "errorMessage": null,
  "createdAt": "2026-05-03T10:14:00.000Z",
  "completedAt": "2026-05-03T10:14:00.150Z",
  "durationMs": 150
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | Render id (UUIDv7). |
| `status` | string | See status progression below. |
| `pollUrl` | string | Self-referential; same as the request URL. |
| `downloadUrl` | string \| null | Populated when `status === 'succeeded'`. A permanent public URL (no presigning, no TTL); `null` if no public bucket is configured. For private or revokable delivery, use the partner-key download stream or a share link. |
| `errorMessage` | string \| null | Populated when `status === 'failed'`. |
| `createdAt` | string | ISO-8601 enqueue timestamp. |
| `completedAt` | string \| null | ISO-8601 terminal-state timestamp. |
| `durationMs` | integer \| null | Wall-clock render duration. |

## Status progression

```
queued → rendering → succeeded
                   → failed
                   → cancelled
```

`succeeded` and `failed` are the terminal states you will observe — once reached, the row never changes. `cancelled` exists in the enum but is not produced today (there is no cancellation path), so branch on `succeeded`/`failed`.

## Polling cadence

A reasonable poll loop:

| Step | Delay |
|---|---|
| First poll | 200ms after the 202 |
| Backoff | 200ms, 300ms, 500ms, 1s, 1s, 1s ... capped at 1s |
| Timeout | 30s — return an error to your caller after that |

There is no synchronous render mode — `options.sync` is accepted by the schema but not honored, and the render endpoint always returns `202` with `status: "queued"`. Either poll (above) or use the outgoing webhook (below). If you'd rather not poll, prefer the webhook.

## Outgoing webhook (preferred for high volume)

Configure an outgoing webhook in your project settings. Craftkit POSTs to your URL when a render reaches a terminal state, signed with HMAC. Saves the polling round-trip and your worker count.

```json
{
  "event": "render.succeeded",
  "renderId": "0193c2c3-...",
  "templateId": "1f2e3d4c-...",
  "status": "succeeded",
  "downloadUrl": "https://cdn.craftkit.dev/.../0193c2c3.pdf",
  "errorMessage": null,
  "createdAt": "2026-05-03T10:14:00.000Z",
  "completedAt": "2026-05-03T10:14:00.150Z"
}
```

Headers include `x-craftkit-event`, `x-craftkit-signature` (HMAC-SHA256 hex of the raw body), `x-craftkit-timestamp`, and `x-craftkit-delivery-id`. Subscribed events are `render.succeeded` and `render.failed`.

Verify the signature with your webhook secret:

**Node.js**
```javascript
import crypto from 'node:crypto';

const expected = crypto
  .createHmac('sha256', process.env.WEBHOOK_SECRET)
  .update(rawBody)
  .digest('hex');

if (expected !== req.headers['x-craftkit-signature']) {
  throw new Error('bad signature');
}
```

**Python**
```python
import hmac, hashlib, os

expected = hmac.new(
    os.environ["WEBHOOK_SECRET"].encode(),
    raw_body,
    hashlib.sha256,
).hexdigest()

if not hmac.compare_digest(expected, request.headers["x-craftkit-signature"]):
    raise ValueError("bad signature")
```

## Best practices

- **Always cap your poll loop.** A runaway poll loop on a stuck render burns API budget. 20 attempts × 1s max = 20s is plenty.
- **Use the outgoing webhook above ~10 renders/min.** Polling at scale wastes round-trips.
- **`downloadUrl` is a permanent public URL** (no presigning, no TTL). If you need expiry or private delivery, mint a share link or use the partner-key download stream rather than handing out the raw URL.
- **Check `durationMs` for capacity planning.** Sustained durations > 1s mean your templates are doing heavy work — consider splitting them.

## Related

- [POST /v1/templates/:slug/render](/documentation/api/render-template) — produces the id you poll
- [Errors](/documentation/api/errors) — what `status: 'failed'` looks like
- [Inbound webhook](/documentation/api/inbound-webhook) — third-party trigger, same status flow


---

<!-- doc:api/inbound-webhook -->
# POST /v1/hooks/:token

Trigger a render from any external system that can POST JSON. The webhook URL and HMAC secret are auto-generated when a template is created. Inbound webhooks share the rendering pipeline with the regular render endpoint, so the same validation, versioning, dashboard view, and outgoing webhooks apply.

```http
POST /v1/hooks/:token
```

`:token` is the per-template inbound token from the **Use this template → Inbound webhook** panel.

## Quick Start

The body is **the variable data, flat** — no `{ data: ... }` wrapper. This shape is intentionally compatible with what most third-party integrations (Stripe, Zapier, Make, n8n) emit out of the box.

**curl**
```bash
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
curl -X POST "https://api.craftkit.dev/v1/hooks/$TOKEN" \
  -H "x-craftkit-signature: $SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"
```

**Node.js**
```javascript
import crypto from 'node:crypto';

const body = JSON.stringify({
  customer: { name: 'Acme Corp' },
  items: [{ name: 'Widget', qty: 5, price: 9.99 }],
  total: 49.95,
});

const signature = crypto
  .createHmac('sha256', process.env.CRAFTKIT_INBOUND_SECRET)
  .update(body)
  .digest('hex');

await fetch(`https://api.craftkit.dev/v1/hooks/${process.env.CRAFTKIT_INBOUND_TOKEN}`, {
  method: 'POST',
  headers: {
    'x-craftkit-signature': signature,
    'Content-Type': 'application/json',
  },
  body,
});
```

**Python**
```python
import hashlib, hmac, json, os, requests

body = json.dumps({
    "customer": {"name": "Acme Corp"},
    "items": [{"name": "Widget", "qty": 5, "price": 9.99}],
    "total": 49.95,
})

signature = hmac.new(
    os.environ["CRAFTKIT_INBOUND_SECRET"].encode(),
    body.encode(),
    hashlib.sha256,
).hexdigest()

requests.post(
    f"https://api.craftkit.dev/v1/hooks/{os.environ['CRAFTKIT_INBOUND_TOKEN']}",
    headers={"x-craftkit-signature": signature, "Content-Type": "application/json"},
    data=body,
)
```

## Request body

```json
{
  "customer": { "name": "Acme Corp" },
  "items": [
    { "name": "Widget", "qty": 5, "price": 9.99 }
  ],
  "total": 49.95
}
```

The keys are the variable manifest keys for the target template (no envelope, no `data` wrapper). The same validation that gates the REST render endpoint applies — invalid payloads return `400 invalid_input_data` with the offending fields in `issues.fieldErrors`.

## Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `token` | string | The per-template inbound token. Identifies the template and authenticates the request. | — |

## HMAC signature

If the sender supports HMAC, include the SHA-256 hex digest of the **raw body** under `x-craftkit-signature`, computed against the template's inbound secret.

| Header | Value |
|---|---|
| `x-craftkit-signature` | Hex-encoded HMAC-SHA256 of the raw body, keyed by the template's inbound secret |

| Condition | Result |
|---|---|
| Signature present and valid | `202 Accepted` (render enqueued) |
| Signature present and invalid | `401 invalid_signature` |
| Signature absent | Accepted by default (no enforcement) |

> **Note** — Pin signature enforcement on per template via the dashboard for production traffic. The unsigned mode is convenient for prototyping with services that can't sign (e.g., a Google Form via Zapier), but always sign in production.

## Response — `202 Accepted`

```json
{
  "id": "0193c2c3-...",
  "status": "queued",
  "pollUrl": "https://api.craftkit.dev/v1/renders/0193c2c3-..."
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | Render id (UUIDv7). |
| `status` | string | Always `queued` on accept. Progresses to `rendering` then `succeeded` \| `failed`. |
| `pollUrl` | string | Poll this with your project bearer token to track the render to completion. |

## Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `invalid_json` | Body wasn't valid JSON | Check `Content-Type` and serialization |
| 400 | `invalid_input_data` | Body didn't match the template manifest | Inspect `issues.fieldErrors` for offending keys |
| 401 | `invalid_signature` | `x-craftkit-signature` was present but didn't match | Recompute the HMAC over the exact raw bytes with the template's inbound secret |
| 404 | `invalid_token` | No template matches this inbound token | Check the token; regenerate it in the dashboard if rotated |
| 409 | `no_published_version` | Template has no published version yet | Publish a version in the dashboard |
| 503 | `queue_unavailable` | Render queue is temporarily unreachable | Retry with backoff |

See [Errors](/documentation/api/errors) for the envelope shape and full code list.

## Use cases

| System | Trigger | Body shape needed |
|---|---|---|
| Stripe | `payment_intent.succeeded` webhook | Map invoice fields from `data.object` in your Stripe handler |
| Zapier / Make | Form submission, Sheets row, etc. | Map fields with the visual mapper to the template's variable keys |
| n8n | Workflow node | `Set` node maps CRM fields to manifest keys |
| Your own backend | Any business event | One fewer round-trip than the API key path |

## Same pipeline

Inbound webhook renders go through the exact same pipeline as `POST /v1/templates/:slug/render`:

- Same Zod validation against the manifest.
- Same versioning (uses the latest published version unless pinned).
- Same render worker.
- Same outgoing webhooks fire on completion.
- Same `render` row in the dashboard.

The only differences are the auth (per-template HMAC vs project bearer token) and the body shape (flat vs enveloped).

## Tips

- **One token per template.** Tokens are scoped to a single template, which means revoking one doesn't affect others.
- **Sign in production.** Toggle "Require signature" in the dashboard for any template that processes real money or PII.
- **Test with Zapier first.** It's the fastest way to verify the manifest mapping is right without writing a server.
- **Match the source's body shape.** If Stripe sends `data.object.amount`, configure your Stripe handler (or Zap) to flatten it to `amount` before posting.

## Related

- [POST /v1/templates/:slug/render](/documentation/api/render-template) — the API-key path
- [GET /v1/renders/:id](/documentation/api/render-status) — same status surface for both triggers
- [Errors](/documentation/api/errors) — `invalid_signature` and validation errors


---

<!-- doc:api/webhooks -->
# Webhooks

Craftkit pushes **outgoing webhooks** to your server as renders, documents, and signature requests change state, so you don't have to poll. Collect-only embed fill forms also deliver their submissions this way (`form.submitted`). Every delivery is signed with HMAC-SHA256 — always verify the signature before trusting a payload.

> Looking to *trigger* a render from an external system instead? That's the [inbound render webhook](/documentation/api/inbound-webhook) (`POST /v1/hooks/:token`), a different endpoint.

## Subscriptions

Create a webhook subscription in the dashboard under **Project → Webhooks**. A subscription has:

| Field | Description |
|---|---|
| `url` | Your HTTPS endpoint. Craftkit `POST`s each subscribed event here. |
| `secret` | The HMAC-SHA256 signing secret. Used to compute the `x-craftkit-signature` header. |
| `events` | The list of event names this subscription receives. New subscriptions default to `render.succeeded` + `render.failed`. |
| `active` | A subscription can be paused; inactive subscriptions receive nothing. |

A subscription only receives the events it is explicitly subscribed to. You can run several subscriptions per project — e.g. separate endpoints (and secrets) for render, document-engagement, and signature events.

## Event triggers

There are four event families. Every event name is a dot-separated `family.action`.

### `render.*` — rendering lifecycle

| Event | Fired when |
|---|---|
| `render.succeeded` | A render job finished and the PDF is available. |
| `render.failed` | A render job failed (`errorMessage` explains why). |

### `document.*` — engagement on a delivered document

Fired as recipients interact with a render you've shared or emailed (see [Shares & delivery](/documentation/api/shares) and [Engagement](/documentation/api/engagement)).

| Event | Fired when |
|---|---|
| `document.share_created` | A share link was created for a render. |
| `document.share_revoked` | A share link was revoked. |
| `document.email_sent` | The render was emailed to a recipient. |
| `document.email_opened` | A recipient opened the delivery email (tracking pixel). |
| `document.viewed` | A recipient opened the shared document. |
| `document.downloaded` | A recipient downloaded the PDF. |
| `document.printed` | A recipient printed the document. |

> Recipient-facing engagement events (`viewed`/`downloaded`/`printed`/`email_opened`) are de-duplicated within a 5-minute window per `(share, event, source IP)`, so a refresh-happy recipient won't spam your endpoint.

### `signature.*` — e-signature lifecycle

Fired as a [signature request](/documentation/api/signatures) progresses. Provider-neutral: payloads carry Craftkit's own status vocabulary, never a third-party event type or identifier.

| Event | Fired when |
|---|---|
| `signature.sent` | A signature request was created and its recipients were emailed. |
| `signature.viewed` | A recipient opened the signing UI. |
| `signature.signed` | A single recipient signed (per-recipient; does **not** move the top-level status). |
| `signature.completed` | All recipients signed and the document is finalized. |
| `signature.declined` | A recipient declined to sign. |
| `signature.expired` | The request passed its expiration window. |
| `signature.cancelled` | The request was cancelled. |

### `form.*` — collect-only fill submissions

Emitted by embed **fill** sessions minted with `form.captureMode: "collect"` (see [Embed sessions](/documentation/embed/sessions-api)). In collect-only mode the fill form is pure data-collection infrastructure: on submit, Craftkit validates the field data and delivers it here **without creating a render or storing the field data**. You persist it in your own system of record, then request the render as a separate follow-up [`POST /v1/templates/:slug/render`](/documentation/api/render-template) call.

| Event | Fired when |
|---|---|
| `form.submitted` | A collect-only fill form was submitted and validated. |

> **Ephemeral / purge-on-ack.** Because collect-only mode retains nothing, the delivery payload (which carries the submitted field data) is held only until your endpoint returns `2xx`, then it is **purged** — the audit row (status/attempts/timestamps) remains without the payload. There is **no pull fallback**: the webhook is the only delivery path, so alert on any `form.submitted` delivery that exhausts its retries (`abandoned`).

> **At-least-once, and no replay.** The collect submit returns `202` only after the `form.submitted` delivery is durably enqueued — but a `503`/`500` from the submit endpoint may *still* have enqueued (and delivered) it, so treat `form.submitted` as **at-least-once**. A submit retry mints a **new** delivery, so `x-craftkit-delivery-id` won't match across a resubmit — **dedupe on the payload's `sessionId`** (stable per fill) rather than on the delivery id alone. And because an `abandoned` delivery is purged, it **cannot be replayed**: keep your endpoint reachable and lean on your own retry if you miss one.

## Delivery format

Each delivery is a `POST` to your subscription `url` with `Content-Type: application/json` and these headers:

| Header | Description |
|---|---|
| `x-craftkit-event` | The event name (e.g. `render.succeeded`). Mirrors the body's `event` field. |
| `x-craftkit-signature` | HMAC-SHA256 of the **raw request body**, keyed with the subscription `secret`, hex-encoded (no prefix). |
| `x-craftkit-timestamp` | Unix epoch seconds when the delivery was sent. Use with the signature to reject stale replays. |
| `x-craftkit-delivery-id` | Stable id for this delivery. The **same id is reused across retries** — use it to dedupe. |
| `user-agent` | `Craftkit-Webhook/1.0`. |

Every body is a JSON object whose first field is `event` (the event name). The remaining fields depend on the family.

## Payload data structures

### `render.*`

| Field | Type | Description |
|---|---|---|
| `event` | string | `render.succeeded` or `render.failed`. |
| `renderId` | string (UUID) | The render this event is about. |
| `templateId` | string (UUID) | The template the render was produced from. |
| `status` | string | Terminal render status (`succeeded` / `failed`). |
| `downloadUrl` | string \| null | Public CDN URL of the PDF on success; `null` when public delivery isn't configured (fetch via the [authenticated download route](/documentation/api/render-download) instead) or on failure. |
| `errorMessage` | string \| null | Populated on `render.failed`; `null` otherwise. |
| `createdAt` | string (ISO-8601) | When the render was created. |
| `completedAt` | string (ISO-8601) \| null | When the render reached its terminal state. |

```json
{
  "event": "render.succeeded",
  "renderId": "0193c2c3-1111-7aaa-8bbb-000000000001",
  "templateId": "0193c2c3-0000-7aaa-8bbb-000000000000",
  "status": "succeeded",
  "downloadUrl": "https://cdn.craftkit.dev/craftkit-renders/…​.pdf",
  "errorMessage": null,
  "createdAt": "2026-06-05T10:00:00.000Z",
  "completedAt": "2026-06-05T10:00:00.420Z"
}
```

### `document.*`

| Field | Type | Description |
|---|---|---|
| `event` | string | The `document.*` event name. |
| `renderId` | string (UUID) | The render (document) that was engaged with. |
| `templateId` | string (UUID) | The template behind the render. |
| `shareId` | string (UUID) \| null | The share link involved, when the event originated from one. |
| `eventId` | string (UUID) | Unique id of the recorded engagement event. |
| `eventType` | string | The bare engagement type (`viewed`, `downloaded`, `printed`, `email_opened`, `email_sent`, `share_created`, `share_revoked`). |
| `actorKind` | string | Who caused it: `recipient`, `partner`, or `system`. |
| `sourceIp` | string \| null | Source IP for recipient-facing events. |
| `userAgent` | string \| null | User-agent for recipient-facing events. |
| `metadata` | object \| null | Any custom metadata attached when the event was recorded. |
| `createdAt` | string (ISO-8601) | When the engagement occurred. |

```json
{
  "event": "document.viewed",
  "renderId": "0193c2c3-1111-7aaa-8bbb-000000000001",
  "templateId": "0193c2c3-0000-7aaa-8bbb-000000000000",
  "shareId": "0193c2c3-3333-7aaa-8bbb-000000000003",
  "eventId": "0193c2c3-4444-7aaa-8bbb-000000000004",
  "eventType": "viewed",
  "actorKind": "recipient",
  "sourceIp": "203.0.113.7",
  "userAgent": "Mozilla/5.0 …",
  "metadata": null,
  "createdAt": "2026-06-05T11:03:00.000Z"
}
```

### `signature.*`

| Field | Type | Description |
|---|---|---|
| `event` | string | The `signature.*` event name. |
| `signatureRequestId` | string (UUID) | The signature request. Use it with [GET /v1/signatures/:id](/documentation/api/signatures). |
| `renderId` | string (UUID) | The render that was sent for signature. |
| `status` | string | Provider-neutral request status (e.g. `sent`, `viewed`, `completed`). Present on lifecycle events. |
| `name` | string | The request name. Present on `signature.sent`. |
| `recipients` | array | Recipient snapshot (name/email/designation/order). Present on `signature.sent`. |
| `reason` | string \| null | Decline/cancel reason, when provided. |

Only `event`, `signatureRequestId`, and `renderId` are guaranteed on every signature event; the rest depend on the event (e.g. `name`/`recipients` on `sent`, `status` on lifecycle events, `reason` on `declined`/`cancelled`).

```json
{
  "event": "signature.completed",
  "signatureRequestId": "0193c2c3-2222-7aaa-8bbb-000000000002",
  "renderId": "0193c2c3-1111-7aaa-8bbb-000000000001",
  "status": "completed"
}
```

### `form.*`

| Field | Type | Description |
|---|---|---|
| `event` | string | `form.submitted`. |
| `sessionId` | string (UUID) | The embed fill session that produced the submission. |
| `templateSlug` | string | Slug of the template the form was scoped to. |
| `templateVersion` | number | Published version number the data was validated against. |
| `renderId` | string (UUID) \| null | `null` in collect-only mode (no render was created); set only if a render was also enqueued. |
| `data` | object | The complete merged, manifest-keyed, coerced field set (prefill + user entries, user wins). Byte-symmetric with what the render API accepts, so you can store-then-render with it verbatim. |
| `submittedAt` | string (ISO-8601) | When the form was submitted. |

The payload is retained only until your endpoint `2xx`s it, then purged (see the ephemeral note above).

```json
{
  "event": "form.submitted",
  "sessionId": "0193c2c3-5555-7aaa-8bbb-000000000005",
  "templateSlug": "e-charterparty",
  "templateVersion": 6,
  "renderId": null,
  "data": { "customer": { "name": "Acme Corp" }, "amount": 42 },
  "submittedAt": "2026-06-05T11:00:00.000Z"
}
```

## Verifying the signature

Recompute the HMAC over the **exact raw body bytes** you received (do not re-serialize the parsed JSON — key ordering and whitespace must match) and compare it to `x-craftkit-signature` in constant time. Optionally reject deliveries whose `x-craftkit-timestamp` is outside a tolerance window to blunt replays.

**Node.js**
```javascript
import { createHmac, timingSafeEqual } from 'node:crypto';

function verify(rawBody, headerSig, secret) {
  const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(headerSig ?? '');
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express: capture the raw body, e.g. app.use(express.raw({ type: 'application/json' }))
app.post('/craftkit-webhook', (req, res) => {
  if (!verify(req.body, req.header('x-craftkit-signature'), process.env.CRAFTKIT_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }
  const evt = JSON.parse(req.body.toString('utf8'));
  switch (evt.event) {
    case 'render.succeeded':
      // evt.downloadUrl, evt.renderId …
      break;
    case 'document.viewed':
      // evt.shareId, evt.actorKind …
      break;
    case 'signature.completed':
      // evt.signatureRequestId …
      break;
  }
  res.sendStatus(200);
});
```

**Python**
```python
import hashlib, hmac

def verify(raw_body: bytes, header_sig: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_sig or "")
```

## Retries & idempotency

- Craftkit treats any `2xx` response as success. Anything else — or a timeout; the budget is **15s per attempt** — is a failure.
- Failed deliveries are retried up to **6 attempts** with backoff. After the final attempt the delivery is marked `abandoned` and not retried again.
- Retries reuse the **same `x-craftkit-delivery-id`**. Make your handler idempotent by keying on it, since the same event may arrive more than once.
- Respond `2xx` quickly and do heavy work asynchronously, so a slow handler doesn't trip the 15s budget and trigger needless retries.
- Subscribe each endpoint to only the events it needs; unmatched events are never delivered.

## Related

- [Digital signatures](/documentation/api/signatures) — send documents for signature and emit `signature.*` events
- [Shares & delivery](/documentation/api/shares) and [Engagement](/documentation/api/engagement) — the source of `document.*` events
- [GET /v1/renders/:id](/documentation/api/render-status) — the poll-based alternative to `render.*` webhooks
- [Inbound render webhook](/documentation/api/inbound-webhook) — trigger a render from an external system
- [Errors](/documentation/api/errors) — error envelope and codes


---

<!-- doc:api/embed-catalogs -->
# POST /v1/embed/catalogs

Publish a new version of a named variable catalog. Each call creates the next version number and marks it as current — the previous version is archived but never deleted.

```http
POST /v1/embed/catalogs
```

## Quick Start

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/embed/catalogs \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-catalog",
    "catalog": {
      "allowCustom": false,
      "namespaces": [
        {
          "key": "customer",
          "label": "Customer",
          "fields": [
            { "key": "customer.name",  "label": "Customer name",  "dataType": "text",   "previewData": "Acme Corp" },
            { "key": "customer.email", "label": "Customer email", "dataType": "email",  "previewData": "hello@acme.com" }
          ]
        }
      ],
      "loops": []
    }
  }'
```

**Node.js**
```typescript
const res = await fetch('https://api.craftkit.dev/v1/embed/catalogs', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'my-catalog',
    catalog: {
      allowCustom: false,
      namespaces: [
        {
          key: 'customer',
          label: 'Customer',
          fields: [
            { key: 'customer.name',  label: 'Customer name',  dataType: 'text',  previewData: 'Acme Corp' },
            { key: 'customer.email', label: 'Customer email', dataType: 'email', previewData: 'hello@acme.com' },
          ],
        },
      ],
      loops: [],
    },
  }),
});

const { id, name, version } = await res.json();
// { id: "0193c2c3-1a2b-7c3d-8e4f-aabbccddeeff", name: "my-catalog", version: 1 }
```

**Python**
```python
import os, requests

res = requests.post(
    "https://api.craftkit.dev/v1/embed/catalogs",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
    json={
        "name": "my-catalog",
        "catalog": {
            "allowCustom": False,
            "namespaces": [
                {
                    "key": "customer",
                    "label": "Customer",
                    "fields": [
                        {"key": "customer.name",  "label": "Customer name",  "dataType": "text",  "previewData": "Acme Corp"},
                        {"key": "customer.email", "label": "Customer email", "dataType": "email", "previewData": "hello@acme.com"},
                    ],
                }
            ],
            "loops": [],
        },
    },
)
result = res.json()
# {"id": "0193c2c3-1a2b-7c3d-8e4f-aabbccddeeff", "name": "my-catalog", "version": 1}
```

## Request body

```json
{
  "name": "my-catalog",
  "catalog": {
    "allowCustom": false,
    "namespaces": [ ... ],
    "loops": [ ... ]
  }
}
```

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Catalog name. Lowercase letters, numbers, and hyphens. Reusing an existing name creates the next version. |
| `catalog` | object | Yes | The full catalog payload — see schema below. |

## Catalog schema

```json
{
  "allowCustom": false,
  "namespaces": [
    {
      "key": "customer",
      "label": "Customer",
      "icon": "user",
      "fields": [
        {
          "key": "customer.name",
          "label": "Customer name",
          "dataType": "text",
          "previewData": "Acme Corp",
          "description": "The company or individual name",
          "format": "optional format string"
        }
      ]
    }
  ],
  "loops": [
    {
      "key": "order.items",
      "label": "Order items",
      "itemFields": [
        { "key": "name",  "label": "Product",    "dataType": "text",     "previewData": "Widget A" },
        { "key": "qty",   "label": "Quantity",   "dataType": "number",   "previewData": 2 },
        { "key": "price", "label": "Unit price", "dataType": "currency", "previewData": 49.99 }
      ],
      "previewData": [
        { "name": "Widget A", "qty": 2, "price": 49.99 },
        { "name": "Widget B", "qty": 1, "price": 99.00 }
      ]
    }
  ]
}
```

### `namespaces[].fields` object

| Field | Type | Required | Description |
|---|---|---|---|
| `key` | string | Yes | Dot-path key used in templates: `customer.name`. Pattern: `^[a-zA-Z_][a-zA-Z0-9_.]*$`. |
| `label` | string | Yes | Display name in the variable picker. |
| `dataType` | string | Yes | One of `text`, `longtext`, `number`, `currency`, `date`, `datetime`, `boolean`, `image`, `url`, `email`, `select`. |
| `options` | `{ value, label }[]` | — | Fixed choices for a `select` field — `value` is stored/validated, `label` is display text. Required (non-empty, unique `value`s) when `dataType` is `select`; ignored otherwise. A value outside the options is rejected with `invalid_input_data` when the resolved template renders. |
| `previewData` | scalar | — | Dummy value shown in the live preview. Doubles as input placeholder in form-fill embeds. |
| `format` | string | — | Formatting hint: `currency:EUR`, `date:DD/MM/YYYY`. |
| `description` | string | — | Helper text shown beneath the field in the variable picker. |

### `loops` object

| Field | Type | Required | Description |
|---|---|---|---|
| `key` | string | Yes | Loop key: `order.items`. Compiles to `{{#each order.items}}...{{/each}}`. |
| `label` | string | Yes | Display name in the picker. |
| `itemFields` | array | Yes | Fields available inside the loop body. Same shape as namespace fields. |
| `previewData` | array | — | Dummy rows for the live preview (max 10). |

## Response

**201 Created**
```json
{
  "id": "0193c2c3-1a2b-7c3d-8e4f-aabbccddeeff",
  "name": "my-catalog",
  "version": 1
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | Catalog row UUID. To pin a session to a specific version, pass `catalogRef: { name, version }` (catalogs are referenced by name + version, not by id). |
| `name` | string | The catalog name you passed. |
| `version` | number | Monotonically increasing version number within this name. |

## Versioning

Calling `POST /v1/embed/catalogs` with the same `name` creates the next version:

```
v1 → archived
v2 → archived
v3 → current ← sessions referencing this name get v3
```

Sessions minted with `catalogRef: { name: "my-catalog" }` always resolve to the current version. To pin a session to a specific version, pass `catalogRef: { name: "my-catalog", version: 2 }`.

## Referencing the catalog in a session

After publishing, reference the catalog by name when minting a session:

```json
{
  "tenant": { "externalId": "org_123", "displayName": "Acme Corp" },
  "actor":  { "externalId": "usr_456", "displayName": "Jane Smith" },
  "catalogRef": { "name": "my-catalog" }
}
```

Or inline a one-off catalog without publishing it first:

```json
{
  "tenant": { ... },
  "actor":  { ... },
  "variableCatalog": { "allowCustom": false, "namespaces": [...], "loops": [] }
}
```

See [Session API (mint & refresh)](/documentation/embed/sessions-api) for the full session mint reference.

## Errors

| Status | Code | Meaning |
|---|---|---|
| 401 | `missing_authorization` | No `Authorization` header. |
| 401 | `invalid_credentials` | API key not found, revoked, or embed not enabled. |
| 400 | `invalid_json` | Request body is not valid JSON. |
| 422 | `invalid_request` | Body or `catalog` failed validation — check `issues` array for field-level details. |
| 500 | `internal_error` | Auth check or catalog persistence threw server-side. Retry; contact support if it persists. |

---
_Last revised: 2026-05-12_


---

<!-- doc:api/errors -->
# Errors

Every Craftkit error response uses one envelope. Switch on `error.code` (stable, machine-readable) in your code; surface `error.message` to developers; inspect `error.issues` for validation problems.

## Quick Start

The envelope:

```json
{
  "error": {
    "code": "invalid_input_data",
    "message": "Variable data did not match the template manifest.",
    "issues": {
      "formErrors": [],
      "fieldErrors": {
        "customer": ["Required"]
      }
    }
  }
}
```

Handling it:

**Node.js**
```javascript
const res = await fetch(url, options);
if (!res.ok) {
  const { error } = await res.json();
  if (error.code === 'invalid_input_data') {
    console.error('Field issues:', error.issues.fieldErrors);
  } else if (error.code === 'rate_limited') {
    await backoff();
  }
  throw new Error(`${error.code}: ${error.message}`);
}
```

**Python**
```python
res = requests.post(url, json=body, headers=headers)
if not res.ok:
    error = res.json()["error"]
    if error["code"] == "invalid_input_data":
        print("Field issues:", error["issues"]["fieldErrors"])
    elif error["code"] == "rate_limited":
        backoff()
    raise RuntimeError(f"{error['code']}: {error['message']}")
```

## Envelope shape

| Field | Type | Description |
|---|---|---|
| `code` | string | Stable, machine-readable identifier. Switch on this. |
| `message` | string | Human-readable. Safe for developers; safe-ish for end users. |
| `issues` | object \| undefined | Present for validation errors only. Mirrors Zod's `flatten()` output: `{ formErrors: string[], fieldErrors: Record<string, string[]> }`. |

## Common codes

| Code | HTTP | When | Action |
|---|---|---|---|
| `missing_authorization` | 401 | No bearer header | Send the API key in `Authorization: Bearer ...` |
| `invalid_credentials` | 401 | Key revoked, unknown, or (on `/v1/embed/*`) embed not enabled for the project | Mint a new key **in the target environment**; for embed endpoints, also enable embed mode in the dashboard |
| `partner_suspended` | 403 | Embed partnership suspended | Contact support |
| `template_not_found` | 404 | Slug doesn't exist in this project | Check the slug + the API key's project scope |
| `version_not_found` | 404 | Pinned version doesn't exist | Omit `options.versionNumber` or pick a real one |
| `no_published_version` | 409 | Template has no published version | Publish a version in the dashboard |
| `invalid_json` | 400 | Body wasn't JSON | Set `Content-Type: application/json` and stringify |
| `invalid_request` | 400 | Top-level shape wrong | See the API reference for the surface you called |
| `invalid_input_data` | 400 | Data didn't match manifest | Inspect `issues.fieldErrors` |
| `invalid_signature` | 401 | HMAC mismatch on inbound webhook | Recompute signature from the raw body |
| `rate_limited` | 429 | Too many requests | Back off with exponential jitter |
| `internal_error` | 500 | Something is wrong on our side | Retry with backoff; check status page |

## Retry semantics

| HTTP | Retry? | Notes |
|---|---|---|
| 4xx (except 429) | No | Fix the request first |
| 429 | Yes | Exponential backoff with jitter |
| 5xx | Yes | POST `/render` is safe to retry — the worker dedupes by `jobId` |
| Network error | Yes | Same backoff strategy |

Recommended backoff: **1s, 2s, 4s, 8s, 16s, capped at 30s, max 5 attempts.**

```javascript
async function withRetry(fn, max = 5) {
  for (let i = 0; i < max; i++) {
    try {
      const res = await fn();
      if (res.status < 500 && res.status !== 429) return res;
    } catch (err) {
      if (i === max - 1) throw err;
    }
    const delay = Math.min(1000 * 2 ** i, 30000) + Math.random() * 250;
    await new Promise((r) => setTimeout(r, delay));
  }
}
```

## Tips

- **Always switch on `code`, not `message`.** Messages are human-readable and may change for clarity. Codes are part of the API contract.
- **Surface `fieldErrors` to your form UI.** Each key in `fieldErrors` is a manifest path; map them straight onto the corresponding input.
- **Don't retry 4xx (except 429).** They mean the request itself is wrong. Retrying just burns quota.
- **Idempotency for 5xx retries.** The render worker dedupes by job hash, so retrying a `POST /render` after a 502 won't enqueue twice.

## Related

- [Authentication](/documentation/api/authentication) — `missing_authorization` and `invalid_credentials`
- [POST /v1/templates/:slug/render](/documentation/api/render-template) — `invalid_input_data` and friends
- [Inbound webhook](/documentation/api/inbound-webhook) — `invalid_signature`

## Changelog

- **Validation library upgraded to Zod v4.** The default validator text inside `error.message` and `error.issues` (`fieldErrors`/`formErrors`) was reworded upstream — `error.code` and the `issues` shape are unchanged, so integrations that switch on `code` (per the Tips above) are unaffected.


---

<!-- doc:api/templates -->
# Template management

Create, read, list, update, and delete templates from a variable manifest — no dashboard or hand-written layout required. Craftkit synthesizes a renderable layout from the manifest and publishes it as a version; render against it with [POST /v1/templates/:slug/render](/documentation/api/render-template).

```http
POST   /v1/templates
GET    /v1/templates
GET    /v1/templates/:slug
PUT    /v1/templates/:slug
DELETE /v1/templates/:slug
```

`:slug` is the template's slug within the authenticated key's project. All five endpoints are scoped to the project that owns the API key.

## Quick Start

Create a template from a manifest, then list it back.

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/templates \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Charter Handover",
    "slug": "charter-handover",
    "manifest": {
      "variables": [
        { "key": "booking.code", "label": "Booking code", "dataType": "text", "required": true },
        { "key": "handover.signedBy", "label": "Signed by", "dataType": "text", "required": true },
        { "key": "handover.signatureImageUrl", "label": "Signature", "dataType": "image" }
      ],
      "loops": [
        { "key": "areas", "label": "Inspection areas", "itemFields": [
          { "key": "areaKey", "label": "Area", "dataType": "text" },
          { "key": "condition", "label": "Condition", "dataType": "text" }
        ] }
      ]
    }
  }'

curl https://api.craftkit.dev/v1/templates \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY"
```

**Node.js**
```javascript
const base = 'https://api.craftkit.dev';
const headers = {
  Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
  'Content-Type': 'application/json',
};

// Idempotent provisioning: create-or-republish at a known slug.
const put = await fetch(`${base}/v1/templates/charter-handover`, {
  method: 'PUT',
  headers,
  body: JSON.stringify({
    name: 'Charter Handover',
    manifest: {
      variables: [
        { key: 'booking.code', label: 'Booking code', dataType: 'text', required: true },
        { key: 'handover.signatureImageUrl', label: 'Signature', dataType: 'image' },
      ],
      loops: [
        {
          key: 'areas',
          label: 'Inspection areas',
          itemFields: [
            { key: 'areaKey', label: 'Area', dataType: 'text' },
            { key: 'condition', label: 'Condition', dataType: 'text' },
          ],
        },
      ],
    },
  }),
});
const { slug, currentVersionNumber } = await put.json();
```

**Python**
```python
import os, requests

base = 'https://api.craftkit.dev'
headers = {'Authorization': f"Bearer {os.environ['CRAFTKIT_API_KEY']}"}

# Fetch the manifest + generated JSON Schema before rendering.
res = requests.get(f'{base}/v1/templates/charter-handover', headers=headers)
template = res.json()
manifest = template['manifest']
json_schema = template['jsonSchema']
```

## POST /v1/templates — create

Creates a template from a manifest, synthesizes a layout, and publishes version 1.

### Request body

```json
{
  "name": "Charter Handover",
  "slug": "charter-handover",
  "description": "Pre/post charter inspection sign-off",
  "manifest": {
    "variables": [
      { "key": "booking.code", "label": "Booking code", "dataType": "text", "required": true },
      { "key": "handover.signatureImageUrl", "label": "Signature", "dataType": "image" }
    ],
    "loops": [
      { "key": "areas", "label": "Inspection areas", "itemFields": [
        { "key": "areaKey", "label": "Area", "dataType": "text" },
        { "key": "condition", "label": "Condition", "dataType": "text" }
      ] }
    ]
  },
  "pageConfig": { "format": "A4", "orientation": "portrait", "margin": "20mm", "printBackground": true }
}
```

| Field | Type | Description | Default |
|---|---|---|---|
| `name` | string | Display name, 1–120 chars. Required. | — |
| `slug` | string | Kebab-case identifier (`^[a-z0-9]+(?:-[a-z0-9]+)*$`), 1–120 chars, unique in the project. Omit to derive one from `name`. A provided slug that already exists returns `409`. | Derived from `name` |
| `description` | string | Optional, up to 280 chars. | `null` |
| `manifest` | object | Variable manifest the render payload binds to: `{ variables: VariableDefinition[], loops: LoopDefinition[] }`. Required. Both arrays must be present. | — |
| `manifest.variables[]` | object | Scalar variables. Each: `key` (dot-path), `label`, `dataType`, optional `required`/`defaultValue`/`format`/`description`/`options`. An `image` variable renders as a data-bound `<img>`; a `select` variable carries an `options` array (see below). | — |
| `manifest.loops[]` | object | Array loops, each rendered as a repeating table. Each: `key` (**dot-free, top-level**), `label`, `itemFields[]` (≥1). A loop `key` containing a dot is rejected with `invalid_loop_key`. | — |
| `layout` | object | Optional CanvasDocument `contentJson` override. When omitted, a layout is synthesized from the manifest. | Synthesized |
| `pageConfig` | object | Optional page format: `format` (`A4` \| `A5` \| `Letter` \| `Legal`), `orientation` (`portrait` \| `landscape`), `margin` (e.g. `20mm`), `printBackground`. | A4 portrait, `20mm`, backgrounds on |

`dataType` is one of: `text`, `longtext`, `number`, `currency`, `date`, `datetime`, `boolean`, `image`, `url`, `email`, `select`.

> **`select` variables.** A variable with `dataType: "select"` must include a non-empty `options` array of `{ value, label }` choices — `value` is stored/validated, `label` is display text. Publishing a `select` with no options is rejected with `invalid_request`; option `value`s must be unique, and a `defaultValue`, if set, must be a string matching one of them. `GET /v1/templates/:slug` returns `options` on the variable and emits it in the generated JSON Schema as a string `enum` (e.g. `{ "type": "string", "enum": ["SHIPOWNER", "CHARTERER"] }`). At render time a value outside the options is rejected with `invalid_input_data`. `options` is optional and only meaningful for `select`, so clients that ignore it are unaffected.
>
> ```json
> {
>   "key": "bunker_cost_party",
>   "label": "Bunker cost party",
>   "dataType": "select",
>   "required": false,
>   "options": [
>     { "value": "SHIPOWNER", "label": "Shipowner" },
>     { "value": "CHARTERER", "label": "Charterer" }
>   ]
> }
> ```

> **Slug collision.** A *derived* slug that collides gets a short random hex suffix appended (e.g. `charter-handover-9f3a1c`) — the response carries the final slug. An *explicit* slug that collides is a hard `409 slug_conflict`.

### Response — `201 Created`

```json
{
  "id": "0193c2c3-...",
  "slug": "charter-handover",
  "currentVersionNumber": 1,
  "manifest": { "variables": ["..."], "loops": ["..."] }
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | New template id (UUID). |
| `slug` | string | Final slug — may differ from a derived input if it collided. |
| `currentVersionNumber` | number | Always `1` for a freshly created template. |
| `manifest` | object | The stored manifest, echoed back. |

## GET /v1/templates — list

Returns every non-deleted template in the project, newest-`updatedAt` first. Lightweight — no manifest or JSON Schema (use [GET /v1/templates/:slug](/documentation/api/templates) for those).

### Response — `200 OK`

```json
{
  "templates": [
    {
      "id": "0193c2c3-...",
      "name": "Charter Handover",
      "slug": "charter-handover",
      "description": null,
      "templateType": "document",
      "currentVersionNumber": 2,
      "published": true,
      "createdAt": "2026-05-01T09:00:00.000Z",
      "updatedAt": "2026-06-01T12:00:00.000Z"
    }
  ]
}
```

| Field | Type | Description |
|---|---|---|
| `templates` | array | Templates in the project, ordered by `updatedAt` descending. |
| `templates[].id` | string | Template id. |
| `templates[].name` | string | Display name. |
| `templates[].slug` | string | Slug used in render and read calls. |
| `templates[].description` | string \| null | Optional description. |
| `templates[].templateType` | string | `document` (manifest-driven default) or `pdf-overlay`. |
| `templates[].currentVersionNumber` | number \| null | Published version number, or `null` if nothing is published yet. |
| `templates[].published` | boolean | `true` when a current version exists. |
| `templates[].createdAt` | string | ISO-8601 timestamp. |
| `templates[].updatedAt` | string | ISO-8601 timestamp. |

## GET /v1/templates/:slug — read one

Fetches one template plus its published **manifest** (the contract your render `data` must satisfy) and an auto-generated **JSON Schema** for client-side validation.

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `slug` | string | Template slug, unique within the project. | — |

### Response — `200 OK`

```json
{
  "id": "0193c2c3-...",
  "name": "Charter Handover",
  "slug": "charter-handover",
  "description": null,
  "templateType": "document",
  "currentVersionNumber": 2,
  "published": true,
  "manifest": {
    "variables": [
      { "key": "booking.code", "label": "Booking code", "dataType": "text", "required": true }
    ],
    "loops": [
      { "key": "areas", "label": "Inspection areas", "itemFields": [
        { "key": "areaKey", "label": "Area", "dataType": "text" }
      ] }
    ]
  },
  "jsonSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": {} },
  "createdAt": "2026-05-01T09:00:00.000Z",
  "updatedAt": "2026-06-01T12:00:00.000Z"
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | Template id. |
| `name` | string | Display name. |
| `slug` | string | Template slug. |
| `description` | string \| null | Optional description. |
| `templateType` | string | `document` or `pdf-overlay`. |
| `currentVersionNumber` | number \| null | Published version number, falling back to the highest version. `null` when unpublished. |
| `published` | boolean | `false` when the template exists but has no published version yet (distinct from a `404`). |
| `manifest` | object \| null | The published manifest (`{ variables, loops }`), or `null` when unpublished. |
| `jsonSchema` | object \| null | Draft-07 JSON Schema derived from the manifest, or `null` when unpublished. |
| `createdAt` | string | ISO-8601 timestamp. |
| `updatedAt` | string | ISO-8601 timestamp. |

## PUT /v1/templates/:slug — create-or-republish

The idempotent companion to create. The URL slug is canonical — any `slug` in the body is ignored. The body is otherwise identical to [create](#post-v1templates--create) (`name`, `manifest`, optional `description`/`layout`/`pageConfig`).

- **Template does not exist** → it is created and published as version 1 → `201`.
- **Template exists** → a new version (n+1) is published and becomes current; `name`, `description`, and the synthesized draft are updated too → `200`.

Existing renders are never affected: each render pins its own `templateVersionId`, so republishing only changes what *new* renders use. Each PUT creates a new version even when the manifest is unchanged — calling PUT N times leaves N versions in history.

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `slug` | string | Canonical kebab-case slug. The URL slug wins; a `slug` field in the body is ignored. | — |

### Response — `201 Created` (created) or `200 OK` (republished)

```json
{
  "id": "0193c2c3-...",
  "slug": "charter-handover",
  "currentVersionNumber": 2,
  "manifest": { "variables": ["..."], "loops": ["..."] }
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | Template id (stable across republishes). |
| `slug` | string | The canonical slug (the one in the URL). |
| `currentVersionNumber` | number | The newly published version. `1` on create; incremented by one on every republish. |
| `manifest` | object | The stored manifest, echoed back. |

> **Idempotent provisioning.** Drive PUT from a setup script: the first run creates, every subsequent run republishes in place. Prefer it over `DELETE` + `POST` when you only need the latest layout — it keeps the same `id` and slug and leaves prior renders intact.

## DELETE /v1/templates/:slug — soft-delete

Soft-deletes the template (sets `deletedAt`) and **tombstones the slug** so the canonical slug can be recreated. Because `(project_id, slug)` is a non-partial unique index, the row's slug is moved aside (`{slug}__deleted__{hex}`) to free the original. The original slug is echoed back. Existing renders are untouched — they pin their own `templateVersionId` and remain fetchable by render id.

A second DELETE of the same slug returns `404` — idempotent in effect.

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `slug` | string | Slug of the template to delete. | — |

### Response — `200 OK`

```json
{
  "id": "0193c2c3-...",
  "slug": "charter-handover",
  "deletedAt": "2026-06-06T00:00:00.000Z"
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | The deleted template's id. |
| `slug` | string | The original (now-freed) slug, echoed back. |
| `deletedAt` | string | ISO-8601 timestamp of the soft-delete. |

## Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `invalid_json` | Body wasn't valid JSON (POST/PUT) | Check `Content-Type` and JSON.stringify |
| 400 | `invalid_request` | Envelope, `manifest`, or `pageConfig` failed validation | Inspect `issues` for the offending fields |
| 400 | `invalid_loop_key` | A loop `key` contains a dot | Use a dot-free top-level key (e.g. `areas`, not `handover.areas`) |
| 401 | `unauthorized` | Missing, invalid, or revoked API key | Send `Authorization: Bearer $CRAFTKIT_API_KEY` |
| 404 | `template_not_found` | No live template with that slug in this project (GET one / PUT-republish path / DELETE) | Check the slug and the key's project scope |
| 409 | `slug_conflict` | An explicit `slug` (POST) or a tombstoned slug (PUT create) is already occupied | Pick a new slug or DELETE the existing template first |
| 409 | `version_conflict` | Concurrent republishes raced for the next version number (PUT) | Retry the request |

See [Errors](/documentation/api/errors) for the envelope shape and full code list.

## Tips

- **Manifest is the contract.** `manifest.variables` are scalars keyed by dot-path; `manifest.loops` are arrays keyed by a dot-free top-level key. The same manifest drives the synthesized layout, the auto-generated JSON Schema, and render-time validation.
- **Validate before rendering.** GET the template, read `jsonSchema`, and validate your render `data` client-side to catch shape errors before enqueuing a job.
- **Layout override.** Pass `layout` (a CanvasDocument `contentJson`) only when you need full control; otherwise let Craftkit synthesize one from the manifest.

## Related

- [POST /v1/templates/:slug/render](/documentation/api/render-template) — enqueue a render against a published template
- [GET /v1/renders/:id](/documentation/api/render-status) — poll a render to completion
- [Errors](/documentation/api/errors) — error envelope and retry semantics
- [Authentication](/documentation/api/authentication) — bearer token format


---

<!-- doc:api/shares -->
# Shares & delivery

Share a succeeded render with recipients — mint durable revokable share links, list and revoke them, or send the document straight to an inbox via Resend. Shares are scoped to the render's project; the render must have status `succeeded` before any share or email can be created.

```http
POST   /v1/renders/:id/shares
GET    /v1/renders/:id/shares
DELETE /v1/renders/:id/shares/:shareId
POST   /v1/renders/:id/email
```

`:id` is a render id (UUIDv7) within the authenticated project. `:shareId` is a share id returned by create or list.

## Create a share

```http
POST /v1/renders/:id/shares
```

Mints a guest-facing share link. The render must have **succeeded** (`409 not_ready` otherwise). For `channel: "email"` the API only records the share row — it does **not** send mail; use `POST /v1/renders/:id/email` to actually deliver.

### Quick Start

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/renders/$RENDER_ID/shares \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "link",
    "message": "Here is your signed agreement.",
    "expiresAt": "2026-12-31T23:59:59Z"
  }'
```

**Node.js**
```javascript
const res = await fetch(`https://api.craftkit.dev/v1/renders/${renderId}/shares`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    channel: 'link',
    message: 'Here is your signed agreement.',
    expiresAt: '2026-12-31T23:59:59Z',
  }),
});
const { shareUrl, shareToken } = await res.json();
```

**Python**
```python
import os, requests

res = requests.post(
    f"https://api.craftkit.dev/v1/renders/{render_id}/shares",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
    json={
        "channel": "link",
        "message": "Here is your signed agreement.",
        "expiresAt": "2026-12-31T23:59:59Z",
    },
)
share = res.json()
```

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `id` | string | Render id (UUIDv7) within the authenticated project. Required. | — |

### Request body

```json
{
  "channel": "link",
  "recipientEmail": "client@acme.com",
  "message": "Here is your signed agreement.",
  "expiresAt": "2026-12-31T23:59:59Z"
}
```

| Field | Type | Description | Default |
|---|---|---|---|
| `channel` | string | `link` or `email`. `link` is a plain copy-and-paste URL; `email` tags the share as email-destined but does **not** send — it requires `recipientEmail` and you must call `POST /v1/renders/:id/email` to deliver. | `link` |
| `recipientEmail` | string | Recipient email (must be a valid address). Required when `channel` is `email`, otherwise optional metadata. | — |
| `message` | string | Optional note shown to the recipient. Max 2000 chars. | — |
| `expiresAt` | string | ISO-8601 timestamp. After this instant the share resolves to not-found on the public side. Omit for no auto-expiry. | No expiry |

### Response — `201 Created`

```json
{
  "id": "0193c2c3-...",
  "shareToken": "cks_8sd9...",
  "shareUrl": "https://www.craftkit.dev/share/cks_8sd9...",
  "channel": "link",
  "recipientEmail": null,
  "message": "Here is your signed agreement.",
  "expiresAt": "2026-12-31T23:59:59.000Z",
  "revokedAt": null,
  "createdAt": "2026-06-05T10:05:00.000Z"
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | Share id. Pass to `DELETE` to revoke. |
| `shareToken` | string | Opaque token (prefixed `cks_`) embedded in `shareUrl`. Stored hashed server-side. |
| `shareUrl` | string | Public link: `{shareBase}/share/{shareToken}`. The base resolves to the partner's custom domain, then `SHARE_BASE_URL`, then `APP_URL`, then the request origin. |
| `channel` | string | `link` or `email`. |
| `recipientEmail` | string \| null | Echoed recipient, or `null` for a plain link. |
| `message` | string \| null | Echoed message. |
| `expiresAt` | string \| null | ISO-8601 expiry, or `null`. |
| `revokedAt` | string \| null | Always `null` on create. |
| `createdAt` | string | ISO-8601 creation timestamp. |

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `invalid_json` | Body wasn't valid JSON | Check `Content-Type` and JSON.stringify |
| 400 | `invalid_request` | Body failed schema validation, or `channel="email"` without `recipientEmail` | Inspect `issues`; supply `recipientEmail` for the email channel |
| 401 | `unauthorized` | Missing/invalid/revoked key | Send a valid `Authorization: Bearer` key |
| 404 | `not_found` | No render with that id in this key's project | Check the id and the key's project scope |
| 409 | `not_ready` | Render has not succeeded yet | Poll the render until `status` is `succeeded` |

## List shares

```http
GET /v1/renders/:id/shares
```

Returns every share for the render, newest first, **including revoked ones**. Each row carries a click count (recipient `viewed` events attributed to the share).

### Quick Start

**curl**
```bash
curl https://api.craftkit.dev/v1/renders/$RENDER_ID/shares \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY"
```

**Node.js**
```javascript
const res = await fetch(`https://api.craftkit.dev/v1/renders/${renderId}/shares`, {
  headers: { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` },
});
const { shares } = await res.json();
```

**Python**
```python
import os, requests

res = requests.get(
    f"https://api.craftkit.dev/v1/renders/{render_id}/shares",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
)
shares = res.json()["shares"]
```

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `id` | string | Render id (UUIDv7) within the authenticated project. Required. | — |

### Response — `200 OK`

```json
{
  "shares": [
    {
      "id": "0193c2c3-...",
      "channel": "link",
      "recipientEmail": null,
      "message": "Here is your signed agreement.",
      "revokedAt": null,
      "revokedReason": null,
      "expiresAt": "2026-12-31T23:59:59.000Z",
      "createdAt": "2026-06-05T10:05:00.000Z",
      "shareToken": "cks_8sd9...",
      "clickCount": 3,
      "shareUrl": "https://www.craftkit.dev/share/cks_8sd9..."
    }
  ]
}
```

| Field | Type | Description |
|---|---|---|
| `shares` | array | Shares for the render, newest first. Revoked shares are included. |
| `shares[].id` | string | Share id. |
| `shares[].channel` | string | `link` or `email`. |
| `shares[].recipientEmail` | string \| null | Recipient, or `null`. |
| `shares[].message` | string \| null | Message shown to the recipient. |
| `shares[].revokedAt` | string \| null | ISO-8601 revocation timestamp, or `null` if active. |
| `shares[].revokedReason` | string \| null | Reason recorded at revoke time (e.g. `revoked_by_partner`), or `null`. |
| `shares[].expiresAt` | string \| null | ISO-8601 expiry, or `null`. |
| `shares[].createdAt` | string | ISO-8601 creation timestamp. |
| `shares[].shareToken` | string | Plain token, re-exposed so a dashboard can re-display copy-link rows. |
| `shares[].clickCount` | integer | Count of recipient `viewed` events attributed to this share. |
| `shares[].shareUrl` | string | `{shareBase}/share/{shareToken}`. |

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 401 | `unauthorized` | Missing/invalid/revoked key | Send a valid `Authorization: Bearer` key |
| 404 | `not_found` | No render with that id in this key's project | Check the id and the key's project scope |

## Revoke a share

```http
DELETE /v1/renders/:id/shares/:shareId
```

Soft-deletes the share by stamping `revokedAt` (reason `revoked_by_partner`). The public `/share/:token` URL stops resolving immediately. Revoking is idempotent only in the sense that an already-revoked or unknown share returns `404` — a share can be revoked once.

### Quick Start

**curl**
```bash
curl -X DELETE https://api.craftkit.dev/v1/renders/$RENDER_ID/shares/$SHARE_ID \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY"
```

**Node.js**
```javascript
const res = await fetch(
  `https://api.craftkit.dev/v1/renders/${renderId}/shares/${shareId}`,
  {
    method: 'DELETE',
    headers: { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` },
  },
);
const { revokedAt } = await res.json();
```

**Python**
```python
import os, requests

res = requests.delete(
    f"https://api.craftkit.dev/v1/renders/{render_id}/shares/{share_id}",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
)
result = res.json()
```

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `id` | string | Render id (UUIDv7) within the authenticated project. Required. | — |
| `shareId` | string | Share id to revoke. Required. | — |

### Response — `200 OK`

```json
{
  "id": "0193c2c3-...",
  "revokedAt": "2026-06-06T09:00:00.000Z"
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | The revoked share id. |
| `revokedAt` | string | ISO-8601 revocation timestamp. |

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 401 | `unauthorized` | Missing/invalid/revoked key | Send a valid `Authorization: Bearer` key |
| 404 | `not_found` | Render not found, or share not found / already revoked | Confirm both ids; a share can only be revoked once |

## Email a render

```http
POST /v1/renders/:id/email
```

Creates an `email`-channel share **and** sends the document via Resend in one call. The render must have **succeeded**. Returns the Resend message id. This endpoint requires email to be configured: `RESEND_API_KEY` plus a sender address (`EMAIL_FROM`, or a per-partner `emailFrom`) — otherwise it returns `503 email_not_configured`.

### Quick Start

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/renders/$RENDER_ID/email \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "client@acme.com",
    "recipientName": "Jane Doe",
    "message": "Here is your signed agreement.",
    "expiresAt": "2026-12-31T23:59:59Z"
  }'
```

**Node.js**
```javascript
const res = await fetch(`https://api.craftkit.dev/v1/renders/${renderId}/email`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    to: 'client@acme.com',
    recipientName: 'Jane Doe',
    message: 'Here is your signed agreement.',
    expiresAt: '2026-12-31T23:59:59Z',
  }),
});
const { emailMessageId, sentAt } = await res.json();
```

**Python**
```python
import os, requests

res = requests.post(
    f"https://api.craftkit.dev/v1/renders/{render_id}/email",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
    json={
        "to": "client@acme.com",
        "recipientName": "Jane Doe",
        "message": "Here is your signed agreement.",
        "expiresAt": "2026-12-31T23:59:59Z",
    },
)
result = res.json()
```

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `id` | string | Render id (UUIDv7) within the authenticated project. Required. | — |

### Request body

```json
{
  "to": "client@acme.com",
  "recipientName": "Jane Doe",
  "message": "Here is your signed agreement.",
  "expiresAt": "2026-12-31T23:59:59Z"
}
```

| Field | Type | Description | Default |
|---|---|---|---|
| `to` | string | Recipient email (must be a valid address). Required. | — |
| `recipientName` | string | Recipient display name used in the email greeting. Max 200 chars. | — |
| `message` | string | Optional note included in the email body. Max 2000 chars. | — |
| `expiresAt` | string | ISO-8601 timestamp. Sets an expiry on the underlying share link. Omit for no auto-expiry. | No expiry |

### Response — `201 Created`

```json
{
  "id": "0193c2c3-...",
  "shareToken": "cks_8sd9...",
  "shareUrl": "https://www.craftkit.dev/share/cks_8sd9...",
  "emailMessageId": "re_abc123",
  "sentAt": "2026-06-06T09:05:00.000Z"
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | The created share id (channel `email`). |
| `shareToken` | string | Opaque token embedded in `shareUrl`. |
| `shareUrl` | string | The link delivered in the email: `{shareBase}/share/{shareToken}`. |
| `emailMessageId` | string | Resend provider message id for the sent email. |
| `sentAt` | string | ISO-8601 timestamp the email was dispatched. |

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `invalid_json` | Body wasn't valid JSON | Check `Content-Type` and JSON.stringify |
| 400 | `invalid_request` | Body failed schema validation (missing/invalid `to`, etc.) | Inspect `issues`; provide a valid `to` address |
| 401 | `unauthorized` | Missing/invalid/revoked key | Send a valid `Authorization: Bearer` key |
| 404 | `not_found` | No render with that id in this key's project | Check the id and the key's project scope |
| 409 | `not_ready` | Render has not succeeded yet | Poll the render until `status` is `succeeded` |
| 502 | `email_send_failed` | Resend rejected the send | Inspect the error message; verify sender domain and recipient |
| 503 | `email_not_configured` | Resend isn't wired on this deployment | Set `RESEND_API_KEY` + `EMAIL_FROM` (or a per-partner sender) |

## Related

- [POST /v1/templates/:slug/render](/documentation/api/render-template) — enqueue the render to share
- [GET /v1/renders/:id](/documentation/api/render-status) — poll the render to `succeeded` before sharing
- [Errors](/documentation/api/errors) — error envelope and retry semantics
- [Authentication](/documentation/api/authentication) — bearer token format


---

<!-- doc:api/engagement -->
# Engagement & analytics

Read aggregate engagement counts and recent activity for a render, and record partner-side events from your own viewer UI. Engagement tracks who viewed, downloaded, printed, or was emailed a rendered document.

```http
GET  /v1/renders/:id/engagement
POST /v1/renders/:id/events
```

`:id` is the render id (UUIDv7) within the authenticated project.

## Quick Start

**curl**
```bash
# Read the engagement summary
curl https://api.craftkit.dev/v1/renders/0193c2c3/engagement \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY"

# Record a partner-side event
curl -X POST https://api.craftkit.dev/v1/renders/0193c2c3/events \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "eventType": "downloaded", "metadata": { "source": "dashboard" } }'
```

**Node.js**
```javascript
const headers = { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` };

const summary = await (
  await fetch('https://api.craftkit.dev/v1/renders/0193c2c3/engagement', { headers })
).json();
console.log(summary.counts.viewed, summary.linkOpens, summary.total);

const res = await fetch('https://api.craftkit.dev/v1/renders/0193c2c3/events', {
  method: 'POST',
  headers: { ...headers, 'Content-Type': 'application/json' },
  body: JSON.stringify({ eventType: 'printed', metadata: { source: 'dashboard' } }),
});
const { recorded } = await res.json();
```

**Python**
```python
import os, requests

headers = {"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"}

summary = requests.get(
    "https://api.craftkit.dev/v1/renders/0193c2c3/engagement",
    headers=headers,
).json()

res = requests.post(
    "https://api.craftkit.dev/v1/renders/0193c2c3/events",
    headers=headers,
    json={"eventType": "viewed", "metadata": {"source": "dashboard"}},
)
recorded = res.json()["recorded"]
```

## Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `id` | string | Render id (UUIDv7). Must belong to the API key's project. | — |

## GET `/v1/renders/:id/engagement`

Returns aggregate counts across all event types, a `linkOpens` tally, the grand total, and the most recent events (newest first, capped at 25).

### Response

```json
{
  "counts": {
    "viewed": 12,
    "downloaded": 3,
    "printed": 1,
    "email_opened": 4,
    "email_sent": 2,
    "share_created": 1,
    "share_revoked": 0
  },
  "linkOpens": 8,
  "total": 23,
  "recent": [
    {
      "id": "0193c2d0-...",
      "eventType": "viewed",
      "actorKind": "recipient",
      "shareId": "0193c2cf-...",
      "sourceIp": "203.0.113.7",
      "userAgent": "Mozilla/5.0 ...",
      "metadata": null,
      "createdAt": "2026-05-03T10:20:00.000Z"
    }
  ]
}
```

| Field | Type | Description |
|---|---|---|
| `counts` | object | Per-type event counts. Always includes all seven keys, zero-filled. |
| `counts.viewed` | integer | Document opens. |
| `counts.downloaded` | integer | PDF downloads. |
| `counts.printed` | integer | Print actions. |
| `counts.email_opened` | integer | Tracking-pixel opens on a sent email. |
| `counts.email_sent` | integer | Emails dispatched (partner audit). |
| `counts.share_created` | integer | Share links minted (partner audit). |
| `counts.share_revoked` | integer | Share links revoked (partner audit). |
| `linkOpens` | integer | Subset of `viewed` events that originated from a shared link (`shareId` is non-null). Distinguishes link traffic from in-dashboard views. |
| `total` | integer | Sum of all `counts`. |
| `recent` | array | Up to 25 most recent events, newest first. |
| `recent[].id` | string | Event id. |
| `recent[].eventType` | string | One of `viewed`, `downloaded`, `printed`, `email_opened`, `email_sent`, `share_created`, `share_revoked`. |
| `recent[].actorKind` | string | Who triggered it: `recipient` (the share recipient), `partner` (you, via the events endpoint or dashboard), or `system` (server-side automation). |
| `recent[].shareId` | string \| null | The originating share link, when the event came through one. |
| `recent[].sourceIp` | string \| null | Best-effort client IP (`x-forwarded-for` first hop, else `x-real-ip`). |
| `recent[].userAgent` | string \| null | Client user-agent string. |
| `recent[].metadata` | object \| null | Arbitrary key/value bag attached when the event was recorded. |
| `recent[].createdAt` | string | ISO-8601 timestamp. |

## POST `/v1/renders/:id/events`

Records a partner-side engagement event from your own viewer UI. Every event written here is stamped `actorKind: "partner"` — you cannot record recipient-side events through this route. Recipient-side `viewed`/`downloaded`/`printed` events are written server-side by the public share page.

### Request body

```json
{
  "eventType": "downloaded",
  "metadata": { "source": "dashboard", "userId": "u_123" }
}
```

| Field | Type | Description | Default |
|---|---|---|---|
| `eventType` | string | Required. One of `viewed`, `downloaded`, `printed`. Other engagement types (`email_*`, `share_*`) are recorded by the system, not this route. | — |
| `metadata` | object | Optional free-form key/value bag (string keys, any JSON values) stored verbatim with the event. | — |

> **Dedupe.** `viewed`, `downloaded`, and `printed` are deduped within a 5-minute window keyed on `(shareId, eventType, sourceIp)`. A duplicate inside that window returns `{ "recorded": false }` and is not written.

### Response

```json
{ "recorded": true }
```

| Field | Type | Description |
|---|---|---|
| `recorded` | boolean | `true` if the event was inserted, `false` if it was deduped within the 5-minute window. |

## Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `invalid_json` | POST body wasn't valid JSON | Check `Content-Type` and JSON.stringify |
| 400 | `invalid_request` | POST body didn't match `{ eventType, metadata? }` | Inspect `issues.fieldErrors`; `eventType` must be `viewed`/`downloaded`/`printed` |
| 401 | `unauthorized` | Missing, invalid, or revoked API key | Send a valid `Authorization: Bearer` key |
| 403 | `forbidden` | Key's project no longer exists | Use a key from an active project |
| 404 | `not_found` | No render with that id in this key's project | Check the id and the API key's project scope |

See [Errors](/documentation/api/errors) for the envelope shape and full code list.

## Related

- [GET /v1/renders/:id](/documentation/api/render-status) — poll the render and read its `downloadUrl`
- [Download a render](/documentation/api/render-download) — authenticated PDF stream
- [Errors](/documentation/api/errors) — error envelope and retry semantics
- [Authentication](/documentation/api/authentication) — bearer token format


---

<!-- doc:api/render-download -->
# Download a render

Stream a succeeded render's PDF straight from storage, authenticated with your project API key. Use this when you don't want to hand out a public bucket URL — the bytes are served behind your bearer token.

```http
GET /v1/renders/:id/download
```

`:id` is the render id (UUIDv7) within the authenticated project.

## Quick Start

**curl**
```bash
curl https://api.craftkit.dev/v1/renders/0193c2c3/download \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  --output render.pdf
```

**Node.js**
```javascript
import { writeFile } from 'node:fs/promises';

const res = await fetch('https://api.craftkit.dev/v1/renders/0193c2c3/download', {
  headers: { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` },
});
if (!res.ok) throw new Error(`download failed: ${res.status}`);
const bytes = Buffer.from(await res.arrayBuffer());
await writeFile('render.pdf', bytes);
```

**Python**
```python
import os, requests

res = requests.get(
    "https://api.craftkit.dev/v1/renders/0193c2c3/download",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
)
res.raise_for_status()
with open("render.pdf", "wb") as f:
    f.write(res.content)
```

## Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `id` | string | Render id (UUIDv7). Must belong to the API key's project and have `status: "succeeded"`. | — |

## Response — `200 OK`

The response body is the raw PDF. There is no JSON envelope on success.

| Header | Value |
|---|---|
| `Content-Type` | `application/pdf` |
| `Content-Length` | Byte length of the PDF (lets you detect truncation) |
| `Content-Disposition` | `attachment; filename="<id>.pdf"` |

The asset is buffered server-side before the response is sent, so a storage read error surfaces as a clean `500` rather than a truncated `200`. Always check `res.ok` (or `raise_for_status()`) before writing the bytes to disk.

## When to use this vs `downloadUrl`

[`GET /v1/renders/:id`](/documentation/api/render-status) returns a `downloadUrl`. Its value depends on deployment config:

| Config | `downloadUrl` returned by `GET /v1/renders/:id` | This route |
|---|---|---|
| `S3_PUBLIC_URL` **set** (e.g. a public R2/MinIO bucket) | A permanent direct public CDN URL to the object — no auth, no TTL | Still works; the equivalent authenticated fetch if you prefer not to expose the public URL |
| `S3_PUBLIC_URL` **not set** | This authenticated route's URL (`…/v1/renders/:id/download`) | The only way to fetch the PDF — there is no public object URL |

In short: when no public bucket is configured, `downloadUrl` points back at this route, and a bearer token is required to fetch the bytes.

## Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 401 | `unauthorized` | Missing, invalid, or revoked API key | Send a valid `Authorization: Bearer` key |
| 403 | `forbidden` | Key's project no longer exists | Use a key from an active project |
| 404 | `not_found` | No render with that id in this key's project | Check the id and the API key's project scope |
| 409 | `conflict` | Render hasn't succeeded yet, or has no stored asset (message includes the current status) | Poll [GET /v1/renders/:id](/documentation/api/render-status) until `status` is `succeeded`, then retry |
| 500 | `internal` | The PDF could not be read from storage | Transient — retry; if it persists, contact support |

See [Errors](/documentation/api/errors) for the envelope shape and full code list.

## Related

- [GET /v1/renders/:id](/documentation/api/render-status) — poll the render and read its `downloadUrl`
- [POST /v1/templates/:slug/render](/documentation/api/render-template) — enqueue the render that produces the PDF
- [Engagement & analytics](/documentation/api/engagement) — record a `downloaded` event after fetching
- [Authentication](/documentation/api/authentication) — bearer token format


---

<!-- doc:api/signatures -->
# Digital signatures

Send a rendered PDF out for digital signature, track its lifecycle, and download the archived signed document and completion certificate. Craftkit sends the rendered PDF for digital signature, emails the recipients, hosts the signing UI, and reports status back — the signature service is handled entirely behind the Craftkit API surface.

```http
POST /v1/signatures
GET  /v1/signatures
GET  /v1/signatures/:id
POST /v1/signatures/:id/cancel
GET  /v1/signatures/:id/download
GET  /v1/signatures/:id/certificate
```

All endpoints authenticate with the project API key (`Authorization: Bearer $CRAFTKIT_API_KEY`) and are scoped to that key's project.

## Create a signature request

```http
POST /v1/signatures
```

Takes a **succeeded** render, submits its PDF to the signature service as an atomic create-and-send signing request, and returns the persisted request at `201`. The render must belong to the API key's project and have a stored PDF asset.

### Quick Start

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/signatures \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sign-order-12345" \
  -d '{
    "renderId": "0193c2c3-1111-7aaa-8bbb-000000000001",
    "name": "Charter handover — BK-12345",
    "recipients": [
      { "firstName": "Jane", "lastName": "Doe", "email": "jane@example.com", "designation": "Signer", "order": 1 }
    ],
    "anchorTags": [
      { "anchorString": "{{sign_here}}", "type": "signature", "recipientIndex": 0, "required": true }
    ],
    "expirationHours": 168
  }'
```

**Node.js**
```javascript
const res = await fetch('https://api.craftkit.dev/v1/signatures', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'sign-order-12345',
  },
  body: JSON.stringify({
    renderId: '0193c2c3-1111-7aaa-8bbb-000000000001',
    name: 'Charter handover — BK-12345',
    recipients: [
      { firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com', designation: 'Signer', order: 1 },
    ],
    anchorTags: [
      { anchorString: '{{sign_here}}', type: 'signature', recipientIndex: 0, required: true },
    ],
    expirationHours: 168,
  }),
});
const signature = await res.json();
```

**Python**
```python
import os, requests

res = requests.post(
    "https://api.craftkit.dev/v1/signatures",
    headers={
        "Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}",
        "Idempotency-Key": "sign-order-12345",
    },
    json={
        "renderId": "0193c2c3-1111-7aaa-8bbb-000000000001",
        "name": "Charter handover — BK-12345",
        "recipients": [
            {"firstName": "Jane", "lastName": "Doe", "email": "jane@example.com", "designation": "Signer", "order": 1},
        ],
        "anchorTags": [
            {"anchorString": "{{sign_here}}", "type": "signature", "recipientIndex": 0, "required": True},
        ],
        "expirationHours": 168,
    },
)
signature = res.json()
```

### Request body

| Field | Type | Description | Default |
|---|---|---|---|
| `renderId` | string (UUID) | The render to sign. Must be in this project and have `status: "succeeded"` with a stored PDF. Required. | — |
| `name` | string | Human-readable name for the request (1–255 chars). | `Signature request <render-id-prefix>` |
| `recipients` | array | 1–20 recipients (see below). Required. | — |
| `fields` | array | Up to 200 explicit field placements by page + coordinates (see below). | — |
| `anchorTags` | array | Up to 200 text-anchor placements (see below). Recommended for Craftkit templates. | — |
| `expirationHours` | integer | Hours until the request expires (1–8760). | Provider default (168) |

At least one `fields` entry **or** `anchorTags` entry is required whenever any recipient is a `Signer`.

#### `recipients[]`

| Field | Type | Description | Default |
|---|---|---|---|
| `firstName` | string | Recipient first name (1–100 chars). Required. | — |
| `lastName` | string | Recipient last name (≤100 chars). | — |
| `email` | string | Recipient email (≤255 chars). Required. | — |
| `designation` | enum | `Signer`, `Approver`, or `CC`. | `Signer` |
| `order` | integer | Signing order, ≥1. When supplied on any recipient, the supplied values must be unique and within `[1, recipientCount]`. | Array position (1-based) |

#### `fields[]`

Explicit placement by page and coordinates. Coordinates are **percentages of page width/height (0–100), origin top-left** — the signature service's coordinate system.

| Field | Type | Description | Default |
|---|---|---|---|
| `recipientIndex` | integer | 0-based index into `recipients`. Must reference an existing recipient. Required. | — |
| `type` | enum | `signature`, `initial`, `text`, `date`, `checkbox`, `dropdown`, `radio_buttons`, or `text_area`. Required. | — |
| `page` | integer | 1-based page number. Required. | — |
| `x` | number | Horizontal position, 0–100 (% of page width). Required. | — |
| `y` | number | Vertical position, 0–100 (% of page height). Required. | — |
| `width` | number | Field width, >0 and ≤100 (% of page width). | Provider default |
| `height` | number | Field height, >0 and ≤100 (% of page height). | Provider default |
| `required` | boolean | Whether the field must be completed. | Provider default |

#### `anchorTags[]`

Placement relative to a text anchor baked into the template (e.g. add `{{sign_here}}` to the template body and reference it here). Unlike `fields`, anchor `width`/`height` are **points relative to the matched text**, not page percentages.

| Field | Type | Description | Default |
|---|---|---|---|
| `anchorString` | string | The literal text to anchor on (1–200 chars). Required. | — |
| `type` | enum | `signature`, `initials`, `text`, `date`, or `checkbox`. Note `initials` (plural) here vs. `initial` in `fields` — this mirrors the signature service's wire vocabulary. Required. | — |
| `recipientIndex` | integer | 0-based index into `recipients`. Must reference an existing recipient. Required. | — |
| `width` | number | Offset width in points, >0 and ≤1000. | Provider default |
| `height` | number | Offset height in points, >0 and ≤1000. | Provider default |
| `required` | boolean | Whether the field must be completed. | Provider default |
| `ignoreIfNotPresent` | boolean | Skip silently if the anchor text isn't found in the document. | Provider default |

> **Idempotency.** Send an `Idempotency-Key` header so a retried POST returns the original request instead of minting (and billing) a second signing envelope. Keys are scoped per project; a replay returns `200` with the original body instead of `201`.

### Response — `201 Created`

```json
{
  "id": "0193c2c3-2222-7aaa-8bbb-000000000002",
  "renderId": "0193c2c3-1111-7aaa-8bbb-000000000001",
  "name": "Charter handover — BK-12345",
  "status": "sent",
  "recipients": [
    {
      "id": "rcp_8e10-12ab34cd56ef",
      "firstName": "Jane",
      "lastName": "Doe",
      "email": "jane@example.com",
      "designation": "Signer",
      "order": 1
    }
  ],
  "expirationHours": 168,
  "signedDownloadUrl": null,
  "certificateUrl": null,
  "errorMessage": null,
  "createdAt": "2026-06-05T10:00:00.000Z",
  "completedAt": null
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | Craftkit signature request id (UUID). Use this for status, cancel, download, and certificate. |
| `renderId` | string | The render that was sent for signature. |
| `name` | string | The request name. |
| `status` | string | Lifecycle status — `sent` on creation. See [status lifecycle](#status-lifecycle). |
| `recipients` | array | Recipient snapshot, now carrying provider-assigned `id`s. May also include `signedAt` / `declinedAt` once those events arrive. |
| `expirationHours` | integer \| null | Hours until expiry, as resolved by the signature service. |
| `signedDownloadUrl` | string \| null | Authenticated Craftkit URL to download the signed PDF (`GET /v1/signatures/:id/download`); `null` until the document is archived. |
| `certificateUrl` | string \| null | Authenticated Craftkit URL to download the completion certificate (`GET /v1/signatures/:id/certificate`); `null` until the certificate is available. Always a Craftkit-owned URL — never a provider domain. |
| `errorMessage` | string \| null | Human-readable error or decline/cancel reason. |
| `createdAt` | string | ISO-8601 timestamp. |
| `completedAt` | string \| null | ISO-8601 timestamp set when the request completes. |

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `invalid_json` | Body wasn't valid JSON | Check `Content-Type` and JSON.stringify |
| 400 | `invalid_request` | Body didn't match the schema | Inspect `issues` for offending fields |
| 402 | `signature_credits_exhausted` | The signature service account is out of credits | Top up the signature service account |
| 404 | `not_found` | No render with that id in this project | Check `renderId` and the key's project scope |
| 409 | `conflict` | Render isn't ready for signing (not `succeeded`, or no PDF) | Wait for the render to succeed, then retry |
| 413 | `document_too_large` | Rendered PDF exceeds the 20MB signing limit | Reduce the document size |
| 500 | `internal` | Failed to load the PDF or persist the request | Retry; if persistent, contact support |
| 502 | `signature_provider_error` | The signature service rejected the create-and-send request | Inspect `message`; verify recipients/fields |
| 503 | `signatures_unavailable` | Digital signatures are not configured on this server | Enable digital signatures, or contact support |

## List signature requests

```http
GET /v1/signatures
```

Returns the project's signature requests, newest first, cursor-paginated.

### Quick Start

**curl**
```bash
curl "https://api.craftkit.dev/v1/signatures?limit=20" \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY"
```

**Node.js**
```javascript
const res = await fetch('https://api.craftkit.dev/v1/signatures?limit=20', {
  headers: { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` },
});
const { signatures, nextCursor, hasMore } = await res.json();
```

**Python**
```python
import os, requests

res = requests.get(
    "https://api.craftkit.dev/v1/signatures",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
    params={"limit": 20},
)
page = res.json()
```

### Query parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `limit` | integer | Page size, 1–100. | `50` |
| `renderId` | string (UUID) | Filter to signature requests for a single render. | — |
| `cursor` | string (ISO-8601) | Keyset cursor — return rows created strictly before this timestamp. Pass the previous page's `nextCursor`. | — |

### Response — `200 OK`

```json
{
  "signatures": [
    {
      "id": "0193c2c3-2222-7aaa-8bbb-000000000002",
      "renderId": "0193c2c3-1111-7aaa-8bbb-000000000001",
      "name": "Charter handover — BK-12345",
      "status": "completed",
      "recipients": [],
      "expirationHours": 168,
      "signedDownloadUrl": "https://api.craftkit.dev/v1/signatures/0193c2c3-2222-7aaa-8bbb-000000000002/download",
      "certificateUrl": "https://api.craftkit.dev/v1/signatures/0193c2c3-2222-7aaa-8bbb-000000000002/certificate",
      "errorMessage": null,
      "createdAt": "2026-06-05T10:00:00.000Z",
      "completedAt": "2026-06-05T11:42:08.000Z"
    }
  ],
  "nextCursor": "2026-06-05T10:00:00.000Z",
  "hasMore": false
}
```

| Field | Type | Description |
|---|---|---|
| `signatures` | array | Signature requests (same shape as the create response). |
| `nextCursor` | string \| null | Pass as `cursor` to fetch the next page; `null` when there are no more rows. |
| `hasMore` | boolean | `true` when a full page was returned and more rows may exist. |

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `invalid_request` | Invalid query parameters | Check `limit`, `renderId`, `cursor` formats |
| 401 | `unauthorized` | Missing/invalid/revoked key | Check the bearer token |

## Get a signature request

```http
GET /v1/signatures/:id
```

Poll a single signature request for its current status and (once available) the signed-document URL and certificate.

### Quick Start

**curl**
```bash
curl https://api.craftkit.dev/v1/signatures/$SIGNATURE_ID \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY"
```

**Node.js**
```javascript
const res = await fetch(`https://api.craftkit.dev/v1/signatures/${id}`, {
  headers: { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` },
});
const signature = await res.json();
```

**Python**
```python
import os, requests

res = requests.get(
    f"https://api.craftkit.dev/v1/signatures/{signature_id}",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
)
signature = res.json()
```

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `id` | string (UUID) | The signature request id. | — |

### Response — `200 OK`

Same shape as the [create response](#response--201-created). Watch `status`, `signedDownloadUrl`, `certificateUrl`, and `completedAt` change as the request progresses.

### Status lifecycle

`status` moves through these values as the signature service delivers lifecycle events:

| Status | Meaning |
|---|---|
| `sent` | Request created; the recipients have been emailed. |
| `viewed` | A recipient opened the signing UI. |
| `declined` | A recipient declined to sign (terminal). |
| `expired` | The request passed its expiration window (terminal). |
| `cancelled` | The request was cancelled (terminal). |
| `completed` | All recipients signed and the document is finalized (terminal). |
| `failed` | Processing failed (terminal). |

Recipient-level signing arrives as a `signature.signed` webhook (and per-recipient `signedAt` timestamps) without moving the top-level `status`. Once the request is `completed`, Craftkit archives the signed PDF to its own storage and populates `signedDownloadUrl` and `certificateUrl`.

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 401 | `unauthorized` | Missing/invalid/revoked key | Check the bearer token |
| 404 | `not_found` | No signature request with that id in this project | Check the id and key's project scope |

## Cancel a signature request

```http
POST /v1/signatures/:id/cancel
```

Cancels an in-flight request with the signature service and marks it `cancelled`. Rejected with `409` when the request is already in a terminal state (`completed`, `declined`, `expired`, `cancelled`, or `failed`).

### Quick Start

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/signatures/$SIGNATURE_ID/cancel \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Customer changed the terms" }'
```

**Node.js**
```javascript
const res = await fetch(`https://api.craftkit.dev/v1/signatures/${id}/cancel`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ reason: 'Customer changed the terms' }),
});
const signature = await res.json();
```

**Python**
```python
import os, requests

res = requests.post(
    f"https://api.craftkit.dev/v1/signatures/{signature_id}/cancel",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
    json={"reason": "Customer changed the terms"},
)
signature = res.json()
```

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `id` | string (UUID) | The signature request id. | — |

### Request body

The body is optional (an empty body is accepted).

| Field | Type | Description | Default |
|---|---|---|---|
| `reason` | string | Cancellation reason (≤500 chars). Stored on the request as `errorMessage`. | — |

### Response — `200 OK`

The updated signature request (same shape as the create response), now with `status: "cancelled"`.

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `invalid_json` | Body wasn't valid JSON | Send an empty body or valid JSON |
| 400 | `invalid_request` | Body didn't match the schema | `reason` must be a string ≤500 chars |
| 401 | `unauthorized` | Missing/invalid/revoked key | Check the bearer token |
| 404 | `not_found` | No signature request with that id in this project | Check the id and key's project scope |
| 409 | `conflict` | Request is already terminal and can't be cancelled | Don't cancel completed/declined/expired/cancelled requests |
| 502 | `signature_provider_error` | The signature service failed to cancel the request | Retry; reconcile via the status endpoint |

## Download the signed PDF

```http
GET /v1/signatures/:id/download
```

Streams the archived signed PDF. The signed document is a sensitive legal artifact, so it is served **only** through the authenticated API (streamed from storage) — it is never reachable from a public bucket URL. Returns `409` until the document has been archived (i.e. until `signedDownloadUrl` is non-null).

### Quick Start

**curl**
```bash
curl https://api.craftkit.dev/v1/signatures/$SIGNATURE_ID/download \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -o signed.pdf
```

**Node.js**
```javascript
const res = await fetch(`https://api.craftkit.dev/v1/signatures/${id}/download`, {
  headers: { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` },
});
const pdf = Buffer.from(await res.arrayBuffer());
```

**Python**
```python
import os, requests

res = requests.get(
    f"https://api.craftkit.dev/v1/signatures/{signature_id}/download",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
)
with open("signed.pdf", "wb") as f:
    f.write(res.content)
```

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `id` | string (UUID) | The signature request id. | — |

### Response — `200 OK`

The signed PDF bytes (`Content-Type: application/pdf`), served as an attachment named `<id>-signed.pdf`.

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 401 | `unauthorized` | Missing/invalid/revoked key | Check the bearer token |
| 404 | `not_found` | No signature request with that id in this project | Check the id and key's project scope |
| 409 | `conflict` | Signed document isn't archived yet | Poll status until `signedDownloadUrl` is set |

## Download the completion certificate

```http
GET /v1/signatures/:id/certificate
```

Streams the completion certificate (the audit trail PDF) for a finished signature request. Like the signed document, the certificate is served **only** through the authenticated API — Craftkit fetches it server-side and streams the bytes back, so the underlying provider URL never appears on the wire. Returns `409` until the certificate is available (i.e. until `certificateUrl` is non-null), and `502` if the document can't be retrieved upstream.

### Quick Start

**curl**
```bash
curl https://api.craftkit.dev/v1/signatures/$SIGNATURE_ID/certificate \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -o certificate.pdf
```

**Node.js**
```javascript
const res = await fetch(`https://api.craftkit.dev/v1/signatures/${id}/certificate`, {
  headers: { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` },
});
const pdf = Buffer.from(await res.arrayBuffer());
```

**Python**
```python
import os, requests

res = requests.get(
    f"https://api.craftkit.dev/v1/signatures/{signature_id}/certificate",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
)
with open("certificate.pdf", "wb") as f:
    f.write(res.content)
```

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `id` | string (UUID) | The signature request id. | — |

### Response — `200 OK`

The completion certificate bytes (`Content-Type: application/pdf`), served as an attachment named `<id>-certificate.pdf`.

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 401 | `unauthorized` | Missing/invalid/revoked key | Check the bearer token |
| 404 | `not_found` | No signature request with that id in this project | Check the id and key's project scope |
| 409 | `conflict` | Completion certificate isn't available yet | Poll status until `certificateUrl` is set |
| 502 | `upstream_error` | The certificate couldn't be retrieved from the signature service | Retry; if persistent, contact support |

## Signature webhook events

If your project has a webhook subscription (configured in the dashboard, see [Webhooks](/documentation/api/webhooks)), Craftkit emits signed outgoing webhooks to your server as the signature request changes state. These ride the same delivery contract as render webhooks: a `POST` of a JSON body with an `event` field, signed with HMAC-SHA256 of the raw body (hex, no prefix) in the `x-craftkit-signature` header, plus `x-craftkit-event`, `x-craftkit-timestamp`, and `x-craftkit-delivery-id`. Verify the signature against the subscription secret before trusting the payload, and respond `2xx` promptly.

| Event | Fired when |
|---|---|
| `signature.sent` | The request was created and recipients were emailed. |
| `signature.viewed` | A recipient opened the signing UI. |
| `signature.signed` | A single recipient signed (per-recipient; does not move the top-level status). |
| `signature.completed` | All recipients signed and the document is finalized. |
| `signature.declined` | A recipient declined to sign. |
| `signature.expired` | The request passed its expiration window. |
| `signature.cancelled` | The request was cancelled. |

A subscription only receives the events it is subscribed to. Each payload carries the Craftkit `event` name plus `signatureRequestId`, `renderId`, and a provider-neutral `status`; subscribe and verify on your side just as you would for `render.*` events. The payload never includes any provider-specific event type or identifier.

```json
{
  "event": "signature.completed",
  "signatureRequestId": "0193c2c3-2222-7aaa-8bbb-000000000002",
  "renderId": "0193c2c3-1111-7aaa-8bbb-000000000001",
  "status": "completed"
}
```

| Field | Type | Description |
|---|---|---|
| `event` | string | The Craftkit event name (e.g. `signature.completed`). |
| `signatureRequestId` | string | The signature request id. |
| `renderId` | string | The render that was sent for signature. |
| `status` | string | Provider-neutral lifecycle status — one of `sent`, `viewed`, `completed`, `declined`, `expired`, `cancelled`. |

## Related

- [Render a template](/documentation/api/render-template) — produce the PDF you send for signature
- [GET /v1/renders/:id](/documentation/api/render-status) — confirm the render succeeded first
- [Webhooks](/documentation/api/webhooks) — subscribe to `signature.*` lifecycle events
- [Errors](/documentation/api/errors) — error envelope and retry semantics
- [Authentication](/documentation/api/authentication) — bearer token format


---

<!-- doc:api/health -->
# Health check

Public liveness and readiness probe for the Craftkit API. Returns `200` when all dependency checks pass and `503` when any are failing. No authentication is required.

```http
GET /v1/health
```

## Quick Start

**curl**

```bash
curl -i https://api.craftkit.dev/v1/health
```

**Node.js**

```javascript
const res = await fetch('https://api.craftkit.dev/v1/health');
const health = await res.json();
const healthy = res.status === 200 && health.status === 'ok';
```

**Python**

```python
import requests

res = requests.get("https://api.craftkit.dev/v1/health")
healthy = res.status_code == 200 and res.json()["status"] == "ok"
```

No `Authorization` header is needed — this endpoint is public.

## Response — `200 OK`

```json
{
  "status": "ok",
  "version": "a1b2c3d",
  "checks": {
    "database": "ok"
  }
}
```

| Field | Type | Description |
|---|---|---|
| `status` | string | `ok` when every check passed, `degraded` when at least one failed. `degraded` is returned with HTTP `503`. |
| `version` | string | Short git commit SHA of the running deployment (first 7 chars), or `local` when not deployed on Vercel. |
| `checks` | object | Per-dependency status map. Each value is `ok` or `error`. |
| `checks.database` | string | `ok` if a `SELECT 1` against Postgres succeeded, `error` otherwise. |

When a dependency is failing, the same shape is returned with `status: "degraded"` and HTTP `503`:

```json
{
  "status": "degraded",
  "version": "a1b2c3d",
  "checks": {
    "database": "error"
  }
}
```

## Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 503 | `degraded` | One or more dependency checks failed (see `checks`) | Inspect the failing check; this signals an outage, not a client error |

This endpoint never returns `4xx` — it takes no input and requires no auth. Treat `200` as healthy
and `503` as degraded; the JSON body is the same shape in both cases.

## Related

- [Authentication](/documentation/api/authentication) — bearer token format for the rest of the API
- [Provisioning (multi-tenant)](/documentation/admin/provision) — admin endpoint for tenant setup
- [Errors](/documentation/api/errors) — error envelope for the authenticated API


---

<!-- doc:embed -->
# 01 — Embed Architecture

> **Mental model**: the partner SaaS owns the data; Craftkit owns the editing
> experience. They never share schemas. Instead, the partner *injects* its
> variable catalog into the embedded builder for the duration of one editing
> session.

> Two iframe surfaces share this architecture: the **builder embed**
> (`/embed/builder`) where partner end-users design templates, and the
> **form-fill embed** (`/embed/form`) where they *fill* a published template
> and produce documents. Same JWT mint flow, same origin pinning, same
> appearance pipeline — only the UI inside the iframe differs. See
> [12-form-route.md](./12-form-route.md) for the form embed's full design.

## The big picture

```
┌─────────────────────────────────┐
│  Partner SaaS                   │
│  ┌───────────────────────────┐  │     ┌──────────────────────────┐
│  │ "Edit document template"  │──┼────▶│  Craftkit Server         │
│  │  (button in their UI)     │  │     │  POST /v1/embed/sessions │
│  └───────────────────────────┘  │◀────│  → JWT (5 min TTL)       │
│             │                   │     └──────────────────────────┘
│             ▼                   │
│  ┌───────────────────────────┐  │
│  │ <iframe src="embed.       │  │     Craftkit Embed UI
│  │   craftkit.dev/builder    │──┼────▶ (catalog injected from JWT,
│  │   ?session_token=JWT" />  │  │      branded to host)
│  └───────────────────────────┘  │
│             ▲                   │
│             │ postMessage events│     ┌──────────────────────────┐
│             │ (template.saved,  │────▶│  Craftkit webhook        │
│             │  template.published)│   │  → partner SaaS backend  │
└─────────────────────────────────┘     └──────────────────────────┘
```

Two communication channels:

- **`window.postMessage`** — client-to-client, low-latency UI
- **Webhooks** — Craftkit → partner backend, durable record-of-truth

This dual channel is exactly how Stripe Elements, Plaid Link, and Zapier
Embed work.

## Three credential tiers

| Credential | Lives where | Used for |
|---|---|---|
| **Secret API key** (`ck_live_*`) | Partner's server only | Mint embed sessions, run renders |
| **Publishable embed key** (`ck_pk_*`) | Can be in browser | Identifies the partner integration; not enough alone |
| **Embed session token** (signed JWT, 5 min TTL) | The iframe URL | Authenticates *one user editing one template once* |

Cookies are dead in third-party iframes (Safari/Chrome block them). The model
is **stateless JWT-in-URL with postMessage refresh** — no cookies, no CORS
pain, no long-lived browser credentials.

## The embed session lifecycle

```
1. Partner backend POSTs /v1/embed/sessions with secret + claims payload
2. Craftkit:
   - Validates partner status, origin, project
   - Stores variable_catalog (if inline) under cat_… reference
   - Upserts tenant + actor by external_ids
   - Mints Ed25519-signed JWT with claims
   - Returns { session_token, iframe_url, expires_at, renew_token }
3. Partner mounts iframe with session_token in URL
4. iframe page server-validates JWT (signature, exp, aud, iss, origin)
5. iframe fetches catalog by ref using session_token
6. Tiptap editor hydrates with catalog injected
7. User edits → builder posts events to parent via postMessage
8. ~30s before exp: iframe → parent → partner backend → /v1/embed/sessions/refresh
9. New JWT replaces in-memory token; URL never changes
10. On publish: webhook fires to partner; postMessage to parent for snappy UX
```

## Why `catalog_ref` is split from inline catalog

Variable catalogs can be large (typical partner has 100–500 fields). Encoding
all of that in a JWT URL would explode past browser URL limits.

Solution: partner POSTs the catalog once when minting the session; Craftkit
stores it server-side under `cat_…`; the JWT carries only the reference.
The embed page fetches it with `Authorization: Bearer <session_token>` on load.

## Server-side validation rules

Every embed page request must pass ALL of these checks before rendering:

1. JWT signature valid against current/previous Ed25519 keys
2. `exp > now`, `nbf ≤ now`, `iat ≤ now`
3. `aud` matches the request's `Host` header
4. `iss` resolves to a partner whose status is `active`
5. The request's `Origin` (or `Referer`) is in the partner's allowed-origins list
6. `ck.partner.project_id` belongs to the partner
7. `ck.scope.template_id` (if present) belongs to the partner's project
   AND the template is not deleted
8. `ck.catalog_ref` is owned by this session (not a different session's catalog)
9. `jti` not yet seen → mark seen for `exp` window (prevents URL replay)

If any fail → render an opaque error page (`/embed/error?code=session_invalid`),
never leak which check failed.

## Renewal flow

```
T-30s before expiry:
  iframe → parent : { type: 'craftkit.session.expiring', seconds: 30 }
  parent → its backend : POST /api/craftkit/refresh-session { renew_token }
  backend → Craftkit  : POST /v1/embed/sessions/refresh
                        { renew_token, ck_live_* }
                    ←   { session_token: "eyJ…", expires_at: ... }
  parent → iframe   : { type: 'craftkit.token.refresh', token: 'eyJ…' }
  iframe acknowledges and replaces in-memory token
```

`renew_token` is single-use (rotates each refresh). Limits replay attacks
if a JWT leaks.

## Data flow: who knows what

Critical privacy property:

| Data | Partner backend | Partner frontend | Craftkit | Iframe |
|---|---|---|---|---|
| Customer's PII | ✅ (their data) | ✅ (their data) | ❌ | ❌ |
| Variable catalog (field names + types) | ✅ | ✅ | ✅ (stored server-side) | ✅ (read via session) |
| Template content (Tiptap JSON, manifest) | ❌ | ❌ | ✅ | ✅ (current session) |
| Render input data | ✅ (when calling /v1/render) | ❌ | ✅ (transient, in renders row) | ❌ |
| Generated PDFs | ✅ (via webhook) | ❌ | ✅ (in S3) | ❌ |

**Craftkit never sees customer PII**. It only sees:
- The shape of the partner's data (via catalog)
- The text the user types in templates
- Render-time input data, which the partner controls

## Rendering: Push vs Pull modes

Once a template is published, the partner needs to feed real data into renders.

### Push mode (default, 95% of cases)

```
Partner generates a charter contract for booking #12345
  ↓
Partner fetches its own data, shapes it into the manifest's keys
  ↓
Partner calls POST https://api.craftkit.dev/v1/templates/charter-contract/render
  with bearer ck_live_* and { data: { ... } }
  ↓
Craftkit renders → fires webhook → partner stores PDF URL
```

### Pull mode (advanced, optional)

The partner registers a **data resolver URL** when minting the embed session:

```json
"resolver_url": "https://saas.com/api/craftkit/resolve",
"resolver_secret": "shared-hmac-secret"
```

Then end users can trigger renders directly from Craftkit's dashboard with
just a record ID:

```
User clicks "Render for booking #12345" inside Craftkit dashboard
  ↓
Craftkit POSTs to https://saas.com/api/craftkit/resolve
  with { template_id, record_id: "12345" }
  ↓
Partner responds with { data: {...} }
  ↓
Craftkit renders → returns PDF URL to caller
```

Most integrations start with Push and add Pull later.

## Form embed (sibling iframe surface)

Alongside the builder, Craftkit ships a **form-fill embed** at
`/embed/form?session_token=…`. It is a runtime sibling of the builder, not
a replacement:

| | Builder embed | Form embed |
|---|---|---|
| Path | `/embed/builder` | `/embed/form` |
| Audience | Template designers | End-users filling a template |
| JWT scope mode | `'edit' \| 'create' \| 'view'` | `'fill'` |
| Output | Saved/published template version | A `render` row + an in-iframe PDF |

Reused unchanged: the `POST /v1/embed/sessions` mint flow, Ed25519 JWT
signing + renewal, the postMessage bus, origin pinning, the appearance
pipeline (`normalizeAppearance` → `craftkit.appearance.set`), and the
`@craftkit/embed` SDK transport. Form sessions add two endpoints
(`POST /v1/embed/form-submit/:sessionId`, `GET /v1/embed/renders/:id`),
new postMessage event types, and `Craftkit.mountForm()` in the SDK.

Renders produced by the form embed land in the same `render` table as
programmatic `/v1/templates/:slug/render` calls; a new `render.source`
column distinguishes `'api' | 'form' | 'partner_supplied' | 'dashboard'`
so the partner's renders list, billing, and webhook delivery all work
uniformly. Full design in [12-form-route.md](./12-form-route.md).

## Deployment topologies

| Tier | Topology | Domain | Effort | Use case |
|---|---|---|---|---|
| **Standard embed** | Hosted iframe at `embed.craftkit.dev` | Craftkit's | Zero infra | Most partners |
| **Branded subdomain** | CNAME `builder.partner.com` → Craftkit edge | Partner's | DNS + cert | Mid-market white-label |
| **Reverse-proxy embed** | Partner proxies `/builder/*` to Craftkit edge | Partner's | Partner-side proxy config | Strict-CSP partners |
| **Dedicated instance** | Single-tenant Craftkit deployment, partner-pinned domain | Partner's | Provisioning automation | Enterprise/regulated |
| **Self-hosted** | Partner runs the whole stack | Partner's | Helm chart + license | Banks, healthcare |

The Craftkit codebase doesn't change between standard and branded — same app
reading host header → tenant config → branding tokens.

---
_Last revised: 2026-05-02_


---

<!-- doc:embed/quickstart -->
# Quickstart

Drop the Craftkit builder or form into your SaaS in three steps. Both surfaces share the same partner setup, the same JWT mint flow, and the same SDK shape — the only difference is `scope.mode` and which `mount*` helper you call. This page covers both.

> **Before you start — two prerequisites:**
>
> 1. **Embed must be enabled for your project.** Dashboard → Project → **Embed → Overview** → **Enable embed mode**. A project API key is rejected by `/v1/embed/sessions` with `invalid_credentials` until this is done — even if the key is valid for other endpoints.
>
> 2. **Your API key must exist in the same environment you're calling.** A key minted in local dev does not exist in production. Always create keys via the dashboard of the target environment. See [Authentication](/documentation/api/authentication#troubleshooting-invalid_credentials) for the full troubleshooting checklist.

## Quick Start — builder embed

Mint a session, render the iframe, listen for events. Your customers design templates inside your app.

**Mint the session (server-side)**
```bash
curl -X POST https://api.craftkit.dev/v1/embed/sessions \
  -H "Authorization: Bearer $CK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant": { "externalId": "acct_42",  "displayName": "Acme Corp" },
    "actor":  { "externalId": "user_99", "email": "ops@acme.com" },
    "scope":  { "mode": "edit" }
  }'
```

Returns:

```json
{
  "session_id": "...",
  "session_token": "ey...",
  "iframe_url": "https://embed.craftkit.dev/embed/builder?session_token=ey...",
  "expires_at": "2026-05-03T10:30:00.000Z",
  "renew_token": "ert_..."
}
```

**Mount the iframe (client-side)**
```javascript
import { Craftkit } from '@craftkit/embed';

const builder = Craftkit.mountBuilder({
  container: '#builder',
  sessionToken: ey,
  refresh: async () => {
    const res = await fetch('/api/craftkit/refresh', { method: 'POST' });
    return (await res.json()).session_token;
  },
});

builder.on('template.published', ({ templateId }) => {
  console.log('User published template', templateId);
});
```

## Quick Start — form embed

Same setup, different mode. Your end-users *fill* a published template's variables and produce documents.

**Mint the session (server-side)**
```bash
curl -X POST https://api.craftkit.dev/v1/embed/sessions \
  -H "Authorization: Bearer $CK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant": { "externalId": "acct_42", "displayName": "Acme Corp" },
    "actor":  { "externalId": "user_99", "email": "user@acme.com" },
    "scope":  {
      "mode": "fill",
      "template_id": "ck_tpl_invoice"
    },
    "permissions": { "submit_form": true }
  }'
```

The `iframe_url` returned points at `/embed/form?session_token=...`.

**Mount the iframe (client-side)**
```javascript
import { Craftkit } from '@craftkit/embed';

const form = Craftkit.mountForm({
  container: '#new-invoice',
  sessionToken: ey,
});

// Optional: dataset prefill from your app's data (stays client-side)
form.on('ready', () => {
  form.setDatasets({
    bookings: {
      label: 'Pick a booking',
      items: myBookings.map((b) => ({
        id: b.id,
        label: `${b.customer} — ${b.month}`,
        values: { 'customer.name': b.customer, 'booking.startDate': b.startDate },
      })),
    },
  });
});

// Optional: Stripe-style submit interception
form.on('submit', async (e) => {
  if (!needsSigning(e.data)) return;          // let default render proceed
  e.preventDefault();
  const altered = await mySigningService(e.data);
  const pdf = await myRenderPipeline(altered);
  await e.complete({ pdfUrl: pdf.url });
});

form.on('completed', ({ renderId, downloadUrl }) => {
  console.log('Document ready:', downloadUrl);
});
```

## Step 1 — Become an embed partner

Dashboard → Project → **Embed → Overview** → **Enable embed mode**. Craftkit issues a publishable key, generates an Ed25519 signing key, and creates your first allowed origin. Your project is now an *embed partner*.

Add the origin(s) where you'll mount the iframe:

| Pattern | Example | Use case |
|---|---|---|
| Exact match | `https://app.acme.com` | Single production host |
| Wildcard subdomain | `https://*.acme.com` | Per-tenant subdomains |

All postMessage events are origin-gated against this list.

## Step 2 — Mint a session

The session JWT is short-lived (5 minutes for `view`, 30 minutes for `edit` and `fill`) and scoped to one tenant + one actor. Mint it from your backend so the partner secret never touches the browser.

| Field | Type | Description |
|---|---|---|
| `tenant.externalId` | string | Stable id of the customer in your system. Used for renders → tenant attribution. |
| `tenant.displayName` | string | Shown in the iframe chrome (and in our admin tools). |
| `actor.externalId` | string | The end-user inside that tenant. |
| `actor.email` | string | Display + audit. |
| `scope.mode` | string | `view`, `edit`, `create`, or `fill` (form embed only). |
| `scope.template_id` | string | Required for `fill`. The published template the form targets. |
| `permissions` | object | Per-mode permission flags (`submit_form`, `save_form_draft`, ...). |

Keep the `renew_token` server-side and exchange it for a fresh `session_token` when the iframe emits `session.expiring`.

## Step 3 — Render the iframe + listen for events

Drop the URL into an iframe on your page (or use the SDK's `mount*` helpers, which handle origin pinning and refresh). Works the same in any framework:

**Vanilla HTML**
```html
<iframe
  src="https://embed.craftkit.dev/embed/builder?session_token=..."
  style="width:100%;height:100%;border:0">
</iframe>
```

**React**
```tsx
<iframe
  src={iframeUrl}
  style={{ width: '100%', height: '100%', border: 0 }}
/>
```

**Vue**
```vue
<iframe :src="iframeUrl" style="width:100%;height:100%;border:0" />
```

Listening for events the manual way (when you don't use the SDK):

```javascript
window.addEventListener('message', (e) => {
  if (e.origin !== 'https://embed.craftkit.dev') return;
  if (e.data?.type === 'craftkit.template.published') {
    console.log('Published:', e.data.payload);
  }
  if (e.data?.type === 'craftkit.form.completed') {
    console.log('Document ready:', e.data.payload.downloadUrl);
  }
});
```

See the [postMessage protocol](/documentation/embed/postmessage) for the full event catalogue.

## Builder vs form — when to use which

| You want... | Use |
|---|---|
| Customers to design their own templates | Builder embed (`scope.mode: 'edit' \| 'create'`) |
| End-users to fill a template and get a PDF | Form embed (`scope.mode: 'fill'`) |
| A read-only preview of a template | Builder embed with `scope.mode: 'view'` |
| Both: customers design + their users fill | Both embeds, two sessions, two iframes |

## Tips

- **Mint the session as late as possible.** Tokens are short-lived; minting one on a button click avoids expiry races.
- **Pin the origin.** Always check `e.origin === 'https://embed.craftkit.dev'` in raw `message` listeners. The SDK does this for you.
- **Use the form embed for the long tail of "create document" UIs.** It saves you from rebuilding a form per template.
- **Datasets stay client-side.** The form embed never sends dataset content to Craftkit — just dropdown labels + selected `id`s for audit.

## Related

- [Embed overview](/documentation/embed) — architecture and partner model
- [Form-fill embeddable](/documentation/embed/form-route) — full reference for the form surface
- [Styling & themes](/documentation/embed/styling) — make the embed look like your product
- [Variable catalog](/documentation/embed/variable-catalog) — show partner-specific fields in the variable picker
- [JWT spec](/documentation/embed/jwt) — token shape, signing, kid rotation
- [postMessage protocol](/documentation/embed/postmessage) — full event catalogue
- [Host SDK](/documentation/embed/sdk) — TypeScript helpers for mount + listen


---

<!-- doc:embed/multi-tenant -->
# Multi-tenant embed — admin provision API

When your SaaS serves **multiple organizations** and each org needs its own isolated
Craftkit project (separate templates, separate renders, separate audit trail), use the
**admin provision** pattern instead of managing one API key per tenant manually.

One call to `POST /v1/admin/provision` idempotently creates a full Craftkit stack for
an org — project, API key, embed partner, signing key, and permission presets — and
returns the API key. Subsequent calls for the same `externalOrgId` decrypt and return
the same key, so the call is safe to make on every request or to cache with a short TTL.

---

## When to use this pattern

| Single-tenant | Multi-tenant (this page) |
|---|---|
| One org uses your app | Many orgs use your app |
| One Craftkit project per deployment | One Craftkit project per org, provisioned on demand |
| Hard-code a single API key in env vars | Resolve the API key per-request via `CRAFTKIT_ADMIN_KEY` |

---

## Prerequisites

1. **Create your Craftkit account and project** — Dashboard → New project.
2. **Enable embed mode** — Dashboard → Project → Embed → Overview → Enable embed mode.
3. **Generate an admin key** — contact Craftkit support or check your project settings for
   the `CRAFTKIT_ADMIN_KEY`. This key is separate from your project API key and has
   elevated privileges: it can provision new projects on behalf of your tenants.

---

## Environment variables

```bash
# Your platform backend
CRAFTKIT_ADMIN_KEY=<your-admin-key>          # Elevated — never expose to clients
CRAFTKIT_BASE_URL=https://www.craftkit.dev   # Omit to use the default
```

The `CRAFTKIT_ADMIN_KEY` must match exactly what is configured on the Craftkit server.
It is used both to **authenticate** the provision request and to **derive the AES-256
encryption key** that protects the stored per-org API keys. Changing it without migrating
the stored records will break decryption for all previously provisioned orgs.

---

## Provision endpoint

```
POST /v1/admin/provision
Authorization: Bearer <CRAFTKIT_ADMIN_KEY>
Content-Type: application/json

{ "externalOrgId": "org-123", "orgName": "Acme Corp" }
```

**Response (first call — org created):**
```json
{
  "projectId":    "28db2719-...",
  "partnerId":    "dd4a3243-...",
  "apiKey":       "ck_live_Pjw...",
  "alreadyExisted": false
}
```

**Response (subsequent calls — org already provisioned):**
```json
{
  "projectId":    "28db2719-...",
  "partnerId":    "dd4a3243-...",
  "apiKey":       "ck_live_Pjw...",
  "alreadyExisted": true
}
```

The `apiKey` is the per-org **project API key**. Use it for:
- Session minting (`POST /v1/embed/sessions`)
- Session refresh (`POST /v1/embed/sessions/refresh`)
- Template listing (`GET /v1/embed/builder/templates`)
- Renders listing (`GET /v1/embed/renders`)

Every org's templates, renders, and sessions are isolated inside their own Craftkit project.

---

## Next.js App Router — complete proxy implementation

### Directory structure

```
app/
  api/
    craftkit/
      lib/
        credentials.ts     ← org-key resolution + caching
        assert-auth.ts     ← auth guard (use your own auth)
      session/
        route.ts           ← POST  — mint embed session
      refresh/
        route.ts           ← POST  — refresh embed session
      templates/
        route.ts           ← GET   — list published templates
      renders/
        route.ts           ← GET   — list renders
        [id]/
          download/
            route.ts       ← GET   — proxy PDF download
```

### `app/api/craftkit/lib/credentials.ts`

```typescript
interface CachedKey {
  apiKey: string
  expiresAt: number
}

const KEY_TTL_MS = 10 * 60 * 1000  // 10-minute cache
const cache = new Map<string, CachedKey>()

export function getCraftkitBaseUrl(): string {
  return (process.env.CRAFTKIT_BASE_URL ?? 'https://www.craftkit.dev').replace(/\/$/, '')
}

/**
 * Resolves the Craftkit API key for an org by calling the admin provision
 * endpoint. Result is cached for 10 minutes. The provision endpoint is
 * idempotent — repeated calls return the same key.
 */
export async function getOrgApiKey(orgId: string): Promise<string> {
  const now = Date.now()
  const cached = cache.get(orgId)
  if (cached && cached.expiresAt > now) return cached.apiKey

  const adminKey = process.env.CRAFTKIT_ADMIN_KEY
  if (!adminKey) throw new Error('CRAFTKIT_ADMIN_KEY is not configured')

  const base = getCraftkitBaseUrl()
  const res = await fetch(`${base}/v1/admin/provision`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${adminKey}`,
    },
    body: JSON.stringify({ externalOrgId: orgId }),
    cache: 'no-store',
  })

  if (!res.ok) {
    const body = await res.text().catch(() => '')
    throw new Error(`Craftkit provision failed: ${res.status}${body ? ` — ${body}` : ''}`)
  }

  const data = (await res.json()) as { apiKey: string }
  const apiKey = data.apiKey
  if (!apiKey) throw new Error('Craftkit provision response missing apiKey')

  cache.set(orgId, { apiKey, expiresAt: now + KEY_TTL_MS })
  return apiKey
}
```

### `app/api/craftkit/session/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { getOrgApiKey, getCraftkitBaseUrl } from '../lib/credentials'
import { assertCraftkitAuth } from '../lib/assert-auth'

type Mode = 'create' | 'edit' | 'fill' | 'view'

const BUILDER_PERMISSIONS = {
  publish: true, saveDraft: true, viewVersionHistory: true,
  changePageSettings: true, rollback: false, delete: false,
  rename: false, createCustomVariables: false,
} as const

const FILL_PERMISSIONS = {
  submitForm: true, saveFormDraft: true,
} as const

export async function POST(req: NextRequest) {
  const authResult = await assertCraftkitAuth()
  if (authResult instanceof NextResponse) return authResult

  const {
    tenantExternalId, tenantDisplayName,
    userId, userEmail, userDisplayName,
    mode, templateExternalId, templateName,
  } = (await req.json()) as {
    tenantExternalId?: string; tenantDisplayName?: string
    userId?: string; userEmail?: string; userDisplayName?: string
    mode?: Mode; templateExternalId?: string; templateName?: string
  }

  if (!tenantExternalId || !userId) {
    return NextResponse.json({ error: 'tenantExternalId and userId are required' }, { status: 400 })
  }

  let apiKey: string
  try {
    apiKey = await getOrgApiKey(tenantExternalId)
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err)
    return NextResponse.json({ error: 'craftkit_provision_failed', detail: msg }, { status: 502 })
  }

  const resolvedMode: Mode = mode ?? 'edit'
  const isFill = resolvedMode === 'fill'

  const scope: Record<string, unknown> = { mode: resolvedMode }
  if (templateExternalId) scope.templateExternalId = templateExternalId
  if (templateName) scope.initialName = templateName

  const body: Record<string, unknown> = {
    tenant: { externalId: tenantExternalId, displayName: tenantDisplayName ?? tenantExternalId },
    actor:  { externalId: userId, email: userEmail, displayName: userDisplayName ?? userEmail ?? userId },
    scope,
    permissions: isFill ? FILL_PERMISSIONS : BUILDER_PERMISSIONS,
  }

  // Optional: attach a pre-published variable catalog
  const catalogName = process.env.CRAFTKIT_CATALOG_NAME
  if (catalogName) body.catalogRef = { name: catalogName }

  const res = await fetch(`${getCraftkitBaseUrl()}/v1/embed/sessions`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
    body: JSON.stringify(body),
  })

  const text = await res.text()
  if (!res.ok) {
    return NextResponse.json(
      { error: 'craftkit_session_failed', status: res.status, body: text },
      { status: res.status },
    )
  }
  return new NextResponse(text, { status: 200, headers: { 'Content-Type': 'application/json' } })
}
```

### `app/api/craftkit/refresh/route.ts`

The refresh uses the **same per-org API key** that minted the session. Pass `orgId` in the
body so the route can resolve the right key — a single fixed key won't work in multi-tenant
because Craftkit validates the session's `partnerId` against the authenticating key.

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { getOrgApiKey, getCraftkitBaseUrl } from '../lib/credentials'
import { assertCraftkitAuth } from '../lib/assert-auth'

export async function POST(req: NextRequest) {
  const authResult = await assertCraftkitAuth()
  if (authResult instanceof NextResponse) return authResult

  const { renewToken, orgId } = (await req.json()) as { renewToken?: string; orgId?: string }

  if (!renewToken) return NextResponse.json({ error: 'renewToken required' }, { status: 400 })
  if (!orgId)      return NextResponse.json({ error: 'orgId required' }, { status: 400 })

  let apiKey: string
  try {
    apiKey = await getOrgApiKey(orgId)
  } catch (err) {
    const msg = err instanceof Error ? err.message : String(err)
    return NextResponse.json({ error: 'craftkit_provision_failed', detail: msg }, { status: 502 })
  }

  const res = await fetch(`${getCraftkitBaseUrl()}/v1/embed/sessions/refresh`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
    body: JSON.stringify({ renewToken }),
    cache: 'no-store',
  })

  const text = await res.text()
  if (!res.ok) {
    return NextResponse.json({ error: 'craftkit_refresh_failed', status: res.status, body: text }, { status: res.status })
  }
  return new NextResponse(text, { status: 200, headers: { 'Content-Type': 'application/json' } })
}
```

### React embed component

```typescript
// src/components/craftkit-embed.tsx
'use client'

import * as React from 'react'

type Mode = 'create' | 'edit' | 'fill' | 'view'

interface SessionResponse {
  session_token: string
  iframe_url:    string
  renew_token:   string
  expires_at:    string
}

interface CraftkitEmbedProps {
  mode:                  Mode
  /** Your org's external ID — used for org-level key provisioning */
  tenantExternalId:      string
  tenantDisplayName?:    string
  /** Your user's ID (logged-in user) */
  userId:                string
  userEmail?:            string
  userDisplayName?:      string
  /** Required for edit/fill modes — Craftkit UUID from template.published event */
  templateExternalId?:   string
  templateName?:         string
  onPublished?:          (payload: { templateId: string; name?: string }) => void
  onCompleted?:          (payload: { renderId: string; downloadUrl: string }) => void
  className?:            string
}

export function CraftkitEmbed({
  mode, tenantExternalId, tenantDisplayName,
  userId, userEmail, userDisplayName,
  templateExternalId, templateName,
  onPublished, onCompleted, className,
}: CraftkitEmbedProps) {
  const iframeRef         = React.useRef<HTMLIFrameElement>(null)
  const renewTokenRef     = React.useRef<string | null>(null)
  const refreshingRef     = React.useRef(false)
  const [iframeUrl, setIframeUrl] = React.useState<string | null>(null)
  const [error, setError]         = React.useState<string | null>(null)

  const mintSession = React.useCallback(async (): Promise<SessionResponse> => {
    const res = await fetch('/api/craftkit/session', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        tenantExternalId, tenantDisplayName,
        userId, userEmail, userDisplayName,
        mode, templateExternalId, templateName,
      }),
    })
    if (!res.ok) throw new Error(`mint failed: ${res.status}`)
    return res.json()
  }, [tenantExternalId, tenantDisplayName, userId, userEmail, userDisplayName,
      mode, templateExternalId, templateName])

  const refreshSession = React.useCallback(async (): Promise<SessionResponse | null> => {
    const renew = renewTokenRef.current
    if (!renew || refreshingRef.current) return null
    refreshingRef.current = true
    try {
      const res = await fetch('/api/craftkit/refresh', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        // orgId must match the tenant used when the session was minted
        body: JSON.stringify({ renewToken: renew, orgId: tenantExternalId }),
      })
      if (!res.ok) return null
      return res.json()
    } finally {
      refreshingRef.current = false
    }
  }, [tenantExternalId])

  // Initial session mint
  React.useEffect(() => {
    if (!tenantExternalId || !userId) return
    let cancelled = false
    mintSession()
      .then((data) => {
        if (cancelled) return
        renewTokenRef.current = data.renew_token
        setIframeUrl(data.iframe_url)
      })
      .catch(() => !cancelled && setError('Failed to load. Please refresh the page.'))
    return () => { cancelled = true }
  }, [mintSession, tenantExternalId, userId])

  // postMessage handler
  React.useEffect(() => {
    if (!iframeUrl) return
    const expectedOrigin = new URL(iframeUrl).origin

    const handler = async (e: MessageEvent) => {
      if (e.origin !== expectedOrigin) return
      const { type, payload } = (e.data ?? {}) as { type?: string; payload?: unknown }
      if (!type) return
      const eventName = type.startsWith('craftkit.') ? type.slice(9) : type

      if (eventName === 'template.published') {
        onPublished?.(payload as { templateId: string; name?: string })
      } else if (eventName === 'form.completed') {
        onCompleted?.(payload as { renderId: string; downloadUrl: string })
      } else if (eventName === 'session.expiring') {
        const next = await refreshSession()
        if (!next) return
        renewTokenRef.current = next.renew_token
        const post = (msg: unknown) =>
          iframeRef.current?.contentWindow?.postMessage(msg, expectedOrigin)
        post({ type: 'craftkit.session.refreshed', token: next.session_token })
        post({ type: 'session.refresh',            token: next.session_token })
      } else if (eventName === 'session.expired') {
        mintSession()
          .then((fresh) => { renewTokenRef.current = fresh.renew_token; setIframeUrl(fresh.iframe_url) })
          .catch(() => setError('Session expired. Please refresh the page.'))
      }
    }

    window.addEventListener('message', handler)
    return () => window.removeEventListener('message', handler)
  }, [iframeUrl, onPublished, onCompleted, refreshSession, mintSession])

  if (error) return <div className="flex h-full items-center justify-center text-red-500 text-sm">{error}</div>
  if (!iframeUrl) return <div className="flex h-full items-center justify-center text-gray-400 text-sm">Loading…</div>

  return (
    <iframe
      ref={iframeRef}
      src={iframeUrl}
      className={className ?? 'w-full h-full border-0'}
      allow="clipboard-write"
    />
  )
}
```

---

## Pitfalls specific to multi-tenant

### Single fixed key breaks session refresh

**Symptom**: Sessions mint correctly but `session.expiring` → refresh returns 401.

**Root cause**: Craftkit validates `partnerId` on session refresh. A session minted with
org A's API key (→ partner A) cannot be refreshed with org B's API key (→ partner B).
Using a single `CRAFTKIT_API_KEY` env var for all refreshes fails for every org that isn't
the one that key belongs to.

**Fix**: Pass `orgId` in the refresh request body and resolve the per-org key with
`getOrgApiKey(orgId)` — exactly as shown in the `refresh/route.ts` above.

### Empty `tenantExternalId` provisions a useless org

**Symptom**: A blank template shows up; renders don't associate with the correct org.

**Root cause**: An empty string `''` passed as `externalOrgId` provisions a catch-all org
called `""` in Craftkit. Every user with an unresolved org ends up sharing it.

**Fix**: Guard against empty values before calling the session route:
```typescript
if (!tenantExternalId) return <LoadingSpinner />
```

### `CRAFTKIT_ADMIN_KEY` mismatch

**Symptom**: Provision succeeds (HTTP 200) but decrypting an existing org's key fails with
a generic error.

**Root cause**: The admin key is used as the AES-256 seed to encrypt the per-org API key
at write time. If the key changes, all stored records become undecryptable.

**Fix**: Treat `CRAFTKIT_ADMIN_KEY` like a database encryption master key — rotate it only
with a full re-encryption migration, never by just updating the env var.

---

## Catalog integration (optional)

A variable catalog pre-populates the template builder's field picker with your data model.
Publish it from your CI/CD pipeline so it stays in sync:

```bash
curl -X POST https://www.craftkit.dev/v1/embed/catalogs \
  -H "Authorization: Bearer $CRAFTKIT_ORG_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "my-catalog-v1",
    "catalog": {
      "allowCustom": false,
      "namespaces": [
        {
          "key": "customer", "label": "Customer",
          "fields": [
            { "key": "customer.name",  "label": "Name",  "dataType": "text",  "required": true },
            { "key": "customer.email", "label": "Email", "dataType": "email", "required": false }
          ]
        }
      ],
      "loops": []
    }
  }'
```

Then reference it in every session by setting `CRAFTKIT_CATALOG_NAME=my-catalog-v1` — the
`session/route.ts` above already picks this up from env.

Fields marked `required: true` in the catalog show an asterisk in the form-fill UI and are
validated before submission. All fields are skippable in dashboard renders regardless of
this flag.

---

## Related

- [Embed quickstart](/documentation/embed/quickstart) — single-tenant starter
- [Integration guide](/documentation/integration-guide) — full phases from API to production
- [Variable catalog](/documentation/embed/variable-catalog) — catalog schema reference
- [Session JWT spec](/documentation/embed/jwt) — token anatomy and refresh
- [Client integration guide](../embed/13-client-integration-guide.md) — pitfall checklist


---

<!-- doc:embed/styling -->
# 11 — Styling the Craftkit Embed

The Craftkit embed runs in an iframe. Cross-origin iframes don't inherit
the host page's CSS, so the styling contract is **JSON, not CSS**. Any
host framework that can produce JSON can brand the embed — React, Vue,
Svelte, Angular, Astro, Solid, vanilla JS, server-rendered HTML, Rails
partials, Laravel Blade, ASP.NET Razor, you name it.

There are **four channels** the host can use to deliver styling. They all
share one schema (`Appearance`) and they compose with documented
precedence — pick whichever channel(s) match your stack.

---

## TL;DR

```jsonc
// The Appearance object — the entire styling contract.
{
  "baseTheme": "light",                 // 'light' | 'dark' | 'auto' | 'shadcn'
  "variables": {
    "colorPrimary": "#0EA5E9",
    "colorPrimaryForeground": "#ffffff",
    "borderRadius": "12px",
    "fontFamily": "'Inter', system-ui, sans-serif"
    // …17 token keys total — see "Variables" below
  },
  "rules": {                            // surgical CSS overrides
    ".ck-publish-button": { "boxShadow": "0 1px 2px rgba(0,0,0,0.06)" },
    ".ck-publish-button:hover": { "boxShadow": "0 4px 12px rgba(14,165,233,0.25)" }
  },
  "layout": {                           // structural toggles
    "showCloseButton": false,
    "density": "compact",
    "locale": "es"
  },
  "stylesheetUrl": "https://app.acme.com/embed-brand.css",
  "fontUrl": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap",
  "logoUrl": "https://app.acme.com/logo.svg"
}
```

---

## Channels

### 1. Mint-time (server-to-server, framework-free)

Embed the appearance object in your call to `POST /v1/embed/sessions`. It
travels in the JWT and is applied before first paint. No frontend code
required on your side.

```http
POST /v1/embed/sessions
Authorization: Bearer ck_live_…
Content-Type: application/json

{
  "tenant":   { "externalId": "acct_42",  "displayName": "Acme Corp" },
  "actor":    { "externalId": "user_99",  "email": "ops@acme.com" },
  "scope":    { "mode": "edit" },
  "appearance": {
    "baseTheme": "light",
    "variables": { "colorPrimary": "#0EA5E9", "borderRadius": "12px" }
  }
}
```

This works from any backend. The shape is JSON. Your backend's framework
is irrelevant — Node, Rails, Laravel, .NET, Python, Go, Elixir, all
identical.

### 2. Mount-time (URL params)

For partners whose frontend chooses styling but whose backend can't.
Append `?theme=…` or `?appearance=<base64-json>` to the iframe `src`.

```html
<iframe src="https://embed.craftkit.dev/embed/builder?session_token=…&theme=dark"></iframe>
```

```js
// JSON object → URL-safe base64
const appearance = { baseTheme: 'dark', variables: { colorPrimary: '#0EA5E9' } };
const b64 = btoa(JSON.stringify(appearance))
  .replace(/\+/g, '-').replace(/\//g, '_');
iframe.src = `${baseUrl}?session_token=${token}&appearance=${b64}`;
```

URL params override mint-time appearance. Useful for live light/dark
toggles where you don't want to re-mint a session.

### 3. Runtime (postMessage)

For live updates after mount: switching themes when the user toggles
dark mode, updating brand color from a settings page, swapping density.
Same call from any framework:

```js
iframe.contentWindow.postMessage(
  {
    type: 'craftkit.appearance.set',
    version: 1,
    payload: {
      appearance: { baseTheme: 'dark', variables: { colorPrimary: '#7C3AED' } }
    }
  },
  'https://embed.craftkit.dev'   // targetOrigin — required for security
);
```

The iframe validates the sender origin against your registered
`embed_origin` rows, then merges the partial appearance on top of the
mint-time baseline and re-applies CSS without re-rendering. It acks
back with:

```js
window.addEventListener('message', (e) => {
  if (e.data?.type === 'craftkit.appearance.applied') {
    console.log('iframe accepted:', e.data.payload.acceptedKeys);
  }
});
```

### 4. Partner stylesheet (CSS escape hatch)

For partners that already maintain a brand stylesheet they don't want to
translate to JSON. Set `appearance.stylesheetUrl` (mint-time, URL, or
runtime) — the embed loads it as a `<link rel="stylesheet">` inside the
iframe, after our defaults so partner CSS wins.

```jsonc
{
  "stylesheetUrl": "https://app.acme.com/embed-brand.css"
}
```

```css
/* https://app.acme.com/embed-brand.css */
.ck-embed-root {
  --primary: #0EA5E9;
  --radius: 12px;
}
.ck-publish-button {
  font-weight: 600;
}
```

**Origin gate.** The stylesheet URL must be HTTPS and on a host you've
registered as an embed origin. Anything else is silently dropped — no
error, just no custom CSS — so a typo never breaks the embed.

---

## Precedence

```
defaults                    ←  embed.css ships these
  ↓
baseTheme                   ←  appearance.baseTheme: 'light' | 'dark' | …
  ↓
branding (legacy)           ←  back-compat: primaryColor, fontUrl, logoUrl
  ↓
project default appearance  ←  embed_partner.default_appearance
                              (Dashboard → Embed → Themes)
  ↓
mint-time appearance        ←  JWT.ck.appearance (per-session override)
  ↓
URL appearance              ←  ?theme= / ?appearance=
  ↓
runtime appearance          ←  postMessage('craftkit.appearance.set', …)
  ↓
partner stylesheet          ←  appearance.stylesheetUrl (cascades last)
```

Each step is **optional**. A partner that sets none gets the neutral
default theme — system fonts, slate primary, a Stripe-y look that's
intentionally generic so it blends into any host.

---

## Variables (`appearance.variables`)

JSON keys map 1:1 to CSS custom properties on `.ck-embed-root`. Every key
is optional. Values are plain CSS strings — hex, rgb(), hsl(), system
keywords, anything CSS understands.

| JSON key | CSS variable | Default | Purpose |
|---|---|---|---|
| `colorPrimary`           | `--primary`              | slate-900   | Buttons, focus rings, link accents |
| `colorPrimaryForeground` | `--primary-foreground`   | white       | Text on primary backgrounds |
| `colorBackground`        | `--background`           | white       | Page surface |
| `colorForeground`        | `--foreground`           | slate-900   | Body text |
| `colorMuted`             | `--muted`                | slate-100   | Subdued surfaces (banner bg) |
| `colorMutedForeground`   | `--muted-foreground`     | slate-500   | Subdued text |
| `colorAccent`            | `--accent`               | slate-100   | Hover state surface |
| `colorAccentForeground`  | `--accent-foreground`    | slate-900   | Text on accent surface |
| `colorBorder`            | `--border`               | slate-200   | Outlines |
| `colorInput`             | `--input`                | slate-200   | Form-field outlines |
| `colorRing`              | `--ring`                 | slate-900   | Focus ring color |
| `colorDanger`            | `--destructive`          | red-500     | Error states |
| `colorSuccess`           | `--success`              | emerald-500 | Success states |
| `colorWarning`           | `--warning`              | amber-500   | Warning states |
| `fontFamily`             | `--font-sans`            | system      | Body font stack |
| `fontFamilyMono`         | `--font-mono`            | system mono | Code/key font |
| `fontSizeBase`           | `--font-size-base`       | 14px        | Root font size |
| `fontWeightNormal`       | `--font-weight-normal`   | 400         | Body weight |
| `fontWeightMedium`       | `--font-weight-medium`   | 500         | Button/heading weight |
| `fontWeightBold`         | `--font-weight-bold`     | 600         | Strong text |
| `borderRadius`           | `--radius`               | 0.5rem      | Corner radius |
| `spacingUnit`            | `--spacing-unit`         | 0.25rem     | Spacing scale base |

---

## Rules (`appearance.rules`)

For surgical overrides where a variable change isn't enough. CSS-like
selector → camelCase property map.

```json
{
  "rules": {
    ".ck-publish-button": { "boxShadow": "0 1px 2px rgba(0,0,0,0.06)" },
    ".ck-publish-button:hover": { "boxShadow": "0 4px 12px rgba(14,165,233,0.25)" },
    ".ck-input": { "fontWeight": "500" },
    ".ck-input:focus": { "borderColor": "#0EA5E9" }
  }
}
```

**Selector allowlist.** Only published `.ck-*` classes are valid (see
[07-admin-ui.md](./07-admin-ui.md)). Optional state suffixes:
`:hover`, `:focus`, `:focus-visible`, `:active`, `:disabled`,
`::placeholder`, `--selected`, `--invalid`. Anything else is silently
dropped.

**Property allowlist.** Color, typography, padding/margin, border,
border-radius, box-shadow, opacity, cursor, transition. Anything else
(position, z-index, display, content, pointer-events, …) is silently
dropped — those properties could break the iframe layout or escape the
widget surface.

**Value sanitization.** Values that contain `<`, `expression(`,
`javascript:`, `@import`, or unbalanced parens are silently dropped.

---

## Layout (`appearance.layout`)

Structural toggles, independent of color/type.

| Key | Type | Purpose |
|---|---|---|
| `showTopBar`         | `boolean` | Show/hide the top bar |
| `showCloseButton`    | `boolean` | Show/hide the embed close button |
| `showPublishButton`  | `boolean` | Show/hide the publish action |
| `showSaveDraftButton`| `boolean` | Show/hide the save-draft action |
| `showTemplateList`   | `boolean` | Show/hide the template-list rail |
| `showRenderHistory`  | `boolean` | Show/hide the render-history rail |
| `density`            | `'comfortable' \| 'compact'` | Spacing scale |
| `locale`             | `string` | UI locale, e.g. `en`, `es`, `de-DE` |

---

## Per-framework recipes

The same JSON, four ways. None require shadcn or Tailwind.

### Vanilla HTML / plain JS

```html
<iframe id="ck"
        src="https://embed.craftkit.dev/embed/builder?session_token=…"
        style="width:100%;height:100%;border:0"></iframe>

<script>
  // Runtime theme toggle — works in any framework or no framework.
  document.querySelector('#dark-toggle').addEventListener('click', () => {
    document.getElementById('ck').contentWindow.postMessage(
      {
        type: 'craftkit.appearance.set',
        version: 1,
        payload: { appearance: { baseTheme: 'dark' } }
      },
      'https://embed.craftkit.dev'
    );
  });
</script>
```

### React / Next.js

```tsx
function CraftkitEmbed({ sessionToken, appearance }) {
  const ref = useRef<HTMLIFrameElement>(null);

  useEffect(() => {
    if (!appearance) return;
    ref.current?.contentWindow?.postMessage(
      { type: 'craftkit.appearance.set', version: 1, payload: { appearance } },
      'https://embed.craftkit.dev',
    );
  }, [appearance]);

  return (
    <iframe
      ref={ref}
      src={`https://embed.craftkit.dev/embed/builder?session_token=${sessionToken}`}
      style={{ width: '100%', height: '100%', border: 0 }}
    />
  );
}
```

### Vue 3

```vue
<script setup>
import { ref, watch } from 'vue';
const props = defineProps(['sessionToken', 'appearance']);
const ifr = ref(null);
watch(() => props.appearance, (a) => {
  ifr.value?.contentWindow?.postMessage(
    { type: 'craftkit.appearance.set', version: 1, payload: { appearance: a } },
    'https://embed.craftkit.dev'
  );
});
</script>

<template>
  <iframe ref="ifr"
          :src="`https://embed.craftkit.dev/embed/builder?session_token=${sessionToken}`"
          style="width:100%;height:100%;border:0" />
</template>
```

### Svelte

```svelte
<script>
  export let sessionToken;
  export let appearance;
  let ifr;
  $: if (ifr && appearance) {
    ifr.contentWindow.postMessage(
      { type: 'craftkit.appearance.set', version: 1, payload: { appearance } },
      'https://embed.craftkit.dev'
    );
  }
</script>

<iframe bind:this={ifr}
        src="https://embed.craftkit.dev/embed/builder?session_token={sessionToken}"
        style="width:100%;height:100%;border:0" />
```

### Angular

```ts
@Component({
  selector: 'craftkit-embed',
  template: `<iframe #ifr [src]="src | safe" style="width:100%;height:100%;border:0"></iframe>`,
})
export class CraftkitEmbed implements OnChanges {
  @Input() sessionToken!: string;
  @Input() appearance?: Appearance;
  @ViewChild('ifr') ifr!: ElementRef<HTMLIFrameElement>;

  get src() {
    return `https://embed.craftkit.dev/embed/builder?session_token=${this.sessionToken}`;
  }
  ngOnChanges(c: SimpleChanges) {
    if (c.appearance && this.ifr) {
      this.ifr.nativeElement.contentWindow?.postMessage(
        { type: 'craftkit.appearance.set', version: 1, payload: { appearance: this.appearance } },
        'https://embed.craftkit.dev'
      );
    }
  }
}
```

### Astro / static site

```astro
---
const { sessionToken } = Astro.props;
---
<iframe id="ck"
        src={`https://embed.craftkit.dev/embed/builder?session_token=${sessionToken}&theme=auto`}
        style="width:100%;height:100%;border:0"></iframe>
```

The `?theme=auto` URL param has the iframe follow the user's OS
`prefers-color-scheme` — works without any client JS.

### Rails / Laravel / Django (server-rendered)

Server-renders the iframe with mint-time appearance baked in:

```erb
<%# Rails: assume @session was minted with appearance already %>
<iframe src="<%= @session.iframe_url %>" style="width:100%;height:100%;border:0"></iframe>
```

The mint API call:

```ruby
# Rails — same call from any backend language
Faraday.post(
  "#{ENV['CRAFTKIT_API']}/v1/embed/sessions",
  {
    tenant:  { externalId: account.id, displayName: account.name },
    actor:   { externalId: current_user.id, email: current_user.email },
    appearance: {
      baseTheme: 'light',
      variables: {
        colorPrimary: account.brand_color,
        fontFamily: "'Inter', system-ui, sans-serif",
        borderRadius: '10px',
      },
    },
  }.to_json,
  { 'Authorization' => "Bearer #{ENV['CK_API_KEY']}", 'Content-Type' => 'application/json' },
)
```

---

## shadcn / Tailwind hosts (opt-in)

If your host is built on shadcn/ui, you already define `--primary`,
`--background`, `--ring`, `--radius`, etc. on `<html>`. Cross-origin
iframes can't read those, so a 12-line snippet on your host mirrors
them into the embed via postMessage:

```js
function pushShadcnAppearance(iframe) {
  const cs = getComputedStyle(document.documentElement);
  const v = (k) => cs.getPropertyValue(k).trim();
  iframe.contentWindow.postMessage({
    type: 'craftkit.appearance.set',
    version: 1,
    payload: {
      appearance: {
        baseTheme: 'shadcn',
        variables: {
          colorPrimary:           v('--primary'),
          colorPrimaryForeground: v('--primary-foreground'),
          colorBackground:        v('--background'),
          colorForeground:        v('--foreground'),
          colorMuted:             v('--muted'),
          colorMutedForeground:   v('--muted-foreground'),
          colorAccent:            v('--accent'),
          colorAccentForeground:  v('--accent-foreground'),
          colorBorder:            v('--border'),
          colorInput:             v('--input'),
          colorRing:              v('--ring'),
          colorDanger:            v('--destructive'),
          borderRadius:           v('--radius'),
        }
      }
    }
  }, 'https://embed.craftkit.dev');
}

document.querySelector('#ck').addEventListener('load', (e) => pushShadcnAppearance(e.target));
```

This is the shadcn equivalent of [Clerk's `baseTheme: shadcn`
preset](https://clerk.com/changelog/2025-07-23-shadcn-theme), adapted
for cross-origin iframes (which can't share CSS variables natively).

---

## Security model

- **Origin-gated postMessage.** The iframe rejects every postMessage from
  an origin not registered in your partner's `embed_origin` table.
- **Selector + property allowlists.** Rules can only target our published
  `.ck-*` classes with whitelisted properties. Layout-killing properties
  (`position`, `z-index`, `display`, `pointer-events`, `content`) are
  silently dropped.
- **Value sanitizer.** Values containing `<`, `javascript:`,
  `expression(`, `@import`, or unbalanced parens are dropped.
- **CSP-gated stylesheetUrl.** `<link href>` only loads if the URL is
  HTTPS and on a registered embed origin. Drive-by injection attempts
  fail closed.
- **No JS injection.** `appearance.rules` cannot define `behavior:` or
  `expression()` (IE-era CSS exec); modern browsers don't execute these
  anyway, but the sanitizer drops them defensively.

---

## See also

- [05 — postMessage Protocol](./05-postmessage-protocol.md) — full event
  catalogue including `craftkit.appearance.set` / `applied`.
- [07 — Admin UI](./07-admin-ui.md) — full `.ck-*` class catalogue with
  DOM context, the stable contract you can target via `rules`.


---

<!-- doc:embed/jwt -->
# 02 — JWT & Session Specification

This document is the authoritative reference for the embed session token format.

## Signing

| | |
|---|---|
| **Algorithm** | `EdDSA` (Ed25519) — small, fast, modern. Asymmetric so partners can verify tokens publicly without secret round-trips |
| **Key rotation** | `kid` header points to current key; previous keys honored for 24h overlap |
| **TTL** | 5 minutes for the session token; 24h for the optional context token |
| **Transport** | URL query param on initial load only (`?session_token=…`); subsequent renewals via `postMessage` *only* — never re-write the URL |
| **Storage in iframe** | In-memory only. Never localStorage / sessionStorage / cookies |

## Claims schema

```jsonc
{
  // Standard JWT claims
  "iss": "ck_pk_live_aB3xQ7…",          // partner's publishable key (issuer)
  "aud": "embed.craftkit.dev",           // expected iframe host
  "sub": "session_01HVRX...",            // session id (audit trail key)
  "iat": 1714657200,
  "exp": 1714657500,                     // iat + 300s
  "nbf": 1714657200,
  "jti": "ck_sess_…",                    // single-use enforcement on renewals

  // Craftkit-specific claims (under "ck" namespace)
  "ck": {
    "v": 1,                              // schema version

    "partner": {
      "id": "ck_partner_01HVR…",         // resolved from publishable key
      "project_id": "ck_proj_01HVR…"     // which project hosts the templates
    },

    "tenant": {
      "external_id": "partner-org-12345",
      "display_name": "Acme Charters Ltd"
    },

    "actor": {
      "external_id": "partner-user-67890",
      "display_name": "Jane Doe",
      "email": "jane@acme.com",          // optional; audit only
      "avatar_url": "https://…"          // optional
    },

    "scope": {
      "mode": "edit",                    // "edit" | "create" | "view" | "fill"
      "template_id": "ck_tpl_01HVR…",    // null when mode=create; required when mode=fill
      "template_external_id": "partner-template-9999"
    },

    "permissions": {
      "publish": true,
      "save_draft": true,
      "delete": false,
      "create_custom_variables": false,  // false = catalog-locked
      "change_page_settings": true,
      "rename": false,
      "view_version_history": true,
      "rollback_version": false,
      "submit_form": true,               // mode=fill: required to POST /v1/embed/form-submit
      "save_form_draft": false           // mode=fill: placeholder, phase 3
    },

    "form": {                            // optional; only consulted when mode=fill
      "prefill": { "customer.name": "Ada Lovelace" },
      "show_preview": true,              // side-by-side live preview pane
      "show_document_after_submit": true,// false = blank/close iframe after submit
      "redirect_url": null               // optional partner "back" URL
    },

    "catalog_ref": "cat_01HVR…",         // pointer to a stored catalog

    "branding": {
      "logo_url": "https://acme.com/logo.svg",
      "primary_color": "#2563EB",
      "font_url": null,
      "locale": "en-GB",
      "ui": {
        "show_top_bar": false,
        "show_template_list": false,
        "show_render_history": false,
        "show_api_keys_link": false,
        "show_publish_button": true,
        "show_save_draft_button": true,
        "show_close_button": true
      }
    },

    "callbacks": {
      "on_published": "https://saas.com/api/craftkit/events",
      "on_close_url": "https://saas.com/back-to-template-list"
    },

    "limits": {
      "max_publishes": 5,
      "max_save_drafts": 50,
      "max_uploads_bytes": 5242880
    },

    "renew_token": "rt_01HVR…"           // opaque; for /v1/embed/sessions/refresh
  }
}
```

## Why split `catalog_ref` from inline catalog

Variable catalogs can be **large** (typical partner has 100–500 fields). Encoding
that in a JWT URL would explode past most browser URL limits.

So the partner POSTs the catalog *once* when minting the session; Craftkit
stores it server-side; the JWT carries only the reference. The embed page
fetches it with `Authorization: Bearer <session_token>` on load.

## Validation rules (server-side)

Every embed page render must pass ALL of these:

1. JWT signature valid against current/previous Ed25519 keys
2. `exp > now`, `nbf ≤ now`, `iat ≤ now`
3. `aud` matches the request's `Host` header
4. `iss` resolves to a partner whose status is `active`
5. The request's `Origin` (or `Referer`) is in the partner's allowed-origins list
6. `ck.partner.project_id` belongs to the partner
7. `ck.scope.template_id` (if present) belongs to the partner's project AND
   the template is not deleted
8. `ck.catalog_ref` is owned by this session
9. `jti` not yet seen → mark seen for `exp` window

If any fail → render `/embed/error?code=session_invalid` with NO leak of
which check failed.

## Form-mode requirements (`scope.mode === 'fill'`)

Form-fill sessions have a few extra invariants on top of the generic
validation list above:

1. `scope.template_id` **or** `scope.template_external_id` must be set
   — the form needs a template to render. Sessions without one resolve
   the form route to `template_not_resolved` (404).
2. The resolved template must have a **published** version. Drafts are
   not fillable. A template with no published version surfaces as
   `unpublished_template` (404) on the API and `template_unpublished`
   on the embed error page.
3. `permissions.submit_form` is required to call
   `POST /v1/embed/form-submit/:sessionId`. Sessions with `submit_form: false`
   still load the form UI (useful as a preview affordance) but submission
   returns `permission_denied` (403).
4. `ck.form.prefill` keys are validated against the resolved variable
   schema at submit time (`invalid_input_data` if a key isn't in the
   manifest). Unknown keys in `prefill` are silently dropped at session
   mint, not at submit, so a stale partner integration can't
   accidentally inject data the template doesn't accept.
5. The other modes (`'edit' | 'create' | 'view'`) cannot call the
   form-submit endpoint — they receive `wrong_mode` (403).

The `ck.form` block is optional; absent claims behave as if `prefill={}`,
`showPreview=false`, `showDocumentAfterSubmit=true`, `redirectUrl=null`.

## Renewal flow

```
T-30s before expiry:
  iframe → parent : { type: 'craftkit.session.expiring', seconds: 30 }
  parent → its backend : POST /api/craftkit/refresh-session { renew_token }
  backend → Craftkit  : POST /v1/embed/sessions/refresh
                        { renew_token, ck_live_* }
                    ←   { session_token: "eyJ…", expires_at: ... }
  parent → iframe   : { type: 'craftkit.token.refresh', token: 'eyJ…' }
  iframe acknowledges and replaces in-memory token

If renewal fails (revoked / partner deleted / actor disabled):
  parent receives 401 → calls iframe `craftkit.session.terminate`
  iframe shows "Session expired — please reopen this template"
```

`renew_token` is single-use (rotates each refresh). Limits replay attacks.

---
_Last revised: 2026-05-02_


---

<!-- doc:embed/postmessage -->
# 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-configured `iframeOrigin`)
- 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](./11-styling.md). |
| `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`:

```js
// 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:

1. `event.source === iframe.contentWindow` (parent side) OR
   `event.source === window.parent` (iframe side)
2. `event.origin === expectedOrigin` (strict equality)
3. `payload` is an object with `type: string`
4. `type` starts with `craftkit.`
5. `version` matches 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_


---

<!-- doc:embed/sdk -->
# 03 — `@craftkit/embed` SDK

The drop-in JavaScript SDK that partners use to mount Craftkit's two embed
surfaces: the **builder** (`mountBuilder`) for designing templates and the
**form-fill embed** (`mountForm`) for end-users to fill a published template
and produce a document. Both share the same `Craftkit.init(...)` client,
the same origin gating, and the same refresh/teardown plumbing.

**Goals:**
- Tiny, dependency-free
- ESM + CJS + UMD builds
- Loadable as `<script>` or via `npm i @craftkit/embed`
- Strict origin pinning + auto-cleanup
- Fully typed events

## Partner integration (the whole code)

```html
<div id="ck-builder-host" style="height: 100vh"></div>

<script type="module">
  import { Craftkit } from 'https://cdn.craftkit.dev/embed/v1/index.js';
  // or: import { Craftkit } from '@craftkit/embed';

  const ck = Craftkit.init({
    publishableKey: 'ck_pk_live_aB3xQ7…',
    debug: false,
  });

  // 1) Ask their backend for a session token
  const session = await fetch('/api/craftkit/embed-session', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ templateId: 'partner-template-9999', mode: 'edit' }),
  }).then(r => r.json());

  // 2) Mount the builder
  const builder = ck.mountBuilder({
    container: '#ck-builder-host',
    sessionToken: session.session_token,
    height: '100%',
    autoResize: true,
    refresh: async () => {
      const r = await fetch('/api/craftkit/embed-session/refresh', {
        method: 'POST',
        body: JSON.stringify({ renewToken: builder.renewToken }),
      }).then(r => r.json());
      return r.session_token;
    },
  });

  // 3) React to events
  builder.on('ready', ({ sessionId }) => console.log('ready', sessionId));
  builder.on('template.published', ({ templateId, version, manifest }) => {
    closeMyModal();
    showToast(`Published v${version}`);
  });
  builder.on('close.requested', () => builder.destroy());
  builder.on('error', (err) => console.error('Craftkit embed error', err));

  // 4) Optional commands
  builder.setTheme({ primaryColor: '#FF0066' });
  builder.loadTemplate('other-template-id');
  builder.setPreviewData({ booking: { id: '12345' } });
</script>
```

## Form-fill quickstart

Mounting the form embed is the same shape — different mount method,
different events. The session token must have `scope.mode === 'fill'`
and resolve to a published template (see [02-jwt-spec.md](./02-jwt-spec.md)).

```html
<div id="ck-form-host" style="height: 720px"></div>

<script type="module">
  import { Craftkit } from '@craftkit/embed';

  const ck = Craftkit.init({ publishableKey: 'ck_pk_live_aB3xQ7…' });

  // 1) Ask their backend for a fill-mode session token
  const session = await fetch('/api/craftkit/embed-session', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ templateId: 'invoice-v3', mode: 'fill' }),
  }).then(r => r.json());

  // 2) Mount the form
  const form = ck.mountForm({
    container: '#ck-form-host',
    sessionToken: session.session_token,
    height: '100%',
    autoResize: true,
    refresh: async () => {
      const r = await fetch('/api/craftkit/embed-session/refresh', {
        method: 'POST',
        body: JSON.stringify({ renewToken: form.renewToken }),
      }).then(r => r.json());
      return r.session_token;
    },
    onCompleted: ({ renderId, downloadUrl, source }) => {
      console.log('Document ready', { renderId, downloadUrl, source });
    },
    onFailed: ({ message, cause }) => console.error('Form failed', cause, message),
  });

  form.on('ready', ({ sessionId }) => console.log('form ready', sessionId));
  form.on('field.changed', ({ key, value }) => console.log(key, value));
</script>
```

### `FormInstance` — the API surface you'll use

```ts
interface FormInstance {
  // Lifecycle
  on(event: FormEventType, handler): () => void;
  off(event: FormEventType, handler): void;
  destroy(): void;

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

  // Datasets (see below)
  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;
}

type FormEventType =
  | 'ready'              // iframe hydrated
  | 'submit'             // pre-submit; interceptable (Stripe-style)
  | 'form.completed'     // post-submit success (default OR partner_supplied)
  | 'form.failed'        // post-submit failure
  | 'form.invalid'       // client-side validation failure (no submit fired)
  | 'field.changed'      // debounced 300ms
  | 'dataset.search'     // lazy dataset queried by user
  | 'dataset.item.requested'
  | 'session.expiring' | 'session.refreshed' | 'session.expired'
  | 'close.requested' | 'error';
```

The full TypeScript shape (event payloads, `EagerDataset`, etc.) lives in
`packages/embed/src/index.ts`.

### Stripe-style submit interception

By default, the iframe enqueues the render and displays the PDF inline.
A parent handler can claim the submit and supply its own PDF instead.
Identical UX from the user's perspective — they always see the document
in the same iframe, only the PDF source changes.

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

  e.preventDefault();                        // claim the submit (within 500ms)
  try {
    const signed = await mySigningService(e.data);
    const pdf = await myRenderPipeline(signed);
    await e.complete({ pdfUrl: pdf.url, metadata: { signedBy: 'acme' } });
    // iframe now shows pdf.url; emits form.completed { source: 'partner_supplied' }
  } catch (err) {
    await e.fail({ message: err.message, cause: err });
  }
});
```

The interception window is **500ms** after `craftkit.form.submit` fires.
A synchronous `e.preventDefault()` always wins. Once claimed, the partner
has up to 60s to call `e.complete()` or `e.fail()` before the iframe
surfaces a `partner_timeout` error.

### Eager dataset prefill

Datasets are partner-supplied lookups (bookings, contacts, etc.) that
let the user pick an item and auto-fill the form. **All dataset content
stays client-side** — Craftkit only sees the `id` of the item the user
picked (stored on `render.dataset_selection` for audit).

```js
form.on('ready', () => {
  form.setDatasets({
    bookings: {
      label: 'Pick a booking',
      items: myBookings.map(b => ({
        id: b.id,                                   // internal correlation
        label: `${b.customer} — ${b.month}`,        // shown in dropdown
        values: {                                   // shallow-merged into form state
          'customer.name': b.customer,
          'booking.startDate': b.startDate,
        },
      })),
    },
  });
});
```

For huge or per-user-authorized datasets, declare a lazy dataset and
respond to `dataset.search` / `dataset.item.requested` events. Single-
select per dataset; multi-select for loop fields is a phase-3 follow-up.
Full design in [12-form-route.md](./12-form-route.md) §7c.

## Public API surface

```ts
namespace Craftkit {
  function init(config: {
    publishableKey: string;
    debug?: boolean;
    iframeOrigin?: string;        // default https://embed.craftkit.dev
    onError?: (err: CraftkitError) => void;
  }): CraftkitClient;
}

interface CraftkitClient {
  mountBuilder(opts: MountBuilderOptions): BuilderInstance;
  mountForm(opts: MountFormOptions): FormInstance;
  // (future: mountTemplatePicker, mountRenderHistory, mountUsageDashboard…)
}

interface MountBuilderOptions {
  container: string | HTMLElement;
  sessionToken: string;
  height?: 'auto' | string | number;
  autoResize?: boolean;
  loadingComponent?: HTMLElement | string;
  refresh: () => Promise<string>;
  sandboxFlags?: string;
  allowFullscreen?: boolean;
}

interface BuilderInstance {
  readonly sessionId: string;
  readonly templateId: string | null;
  readonly renewToken: string;
  readonly iframeElement: HTMLIFrameElement;

  on<E extends keyof BuilderEvents>(
    event: E,
    handler: (payload: BuilderEvents[E]) => void
  ): () => void;
  off<E extends keyof BuilderEvents>(event: E, handler: Function): void;

  setTheme(theme: Partial<{ primaryColor: string; logoUrl: string }>): void;
  loadTemplate(externalId: string): Promise<void>;
  setPreviewData(data: Record<string, unknown>): void;
  triggerSave(): Promise<{ version: number }>;
  triggerPublish(): Promise<{ version: number }>;
  focus(): void;

  destroy(): void;
  isDestroyed(): boolean;
}

interface BuilderEvents {
  'ready':               { sessionId: string; templateId: string | null };
  'template.saved':      { templateId: string; version: number; manifest: VariableManifest };
  'template.published':  { templateId: string; version: number; manifest: VariableManifest };
  'variable.inserted':   { key: string; type: string; source: 'catalog' | 'custom' };
  'variable.removed':    { key: string };
  'session.expiring':    { secondsRemaining: number };
  'session.refreshed':   { newExpiresAt: string };
  'session.expired':     {};
  'height.changed':      { heightPx: number };
  'close.requested':     {};
  'error':               CraftkitError;
}

interface CraftkitError {
  code:
    | 'session_invalid'
    | 'session_expired'
    | 'origin_not_allowed'
    | 'permission_denied'
    | 'iframe_load_failed'
    | 'refresh_failed'
    | 'rate_limited'
    | 'unknown';
  message: string;
  recoverable: boolean;
}
```

## Internal architecture

```
┌─────────────────── Partner page ────────────────────┐
│                                                       │
│  CraftkitClient                                       │
│   ├─ origin allowlist check                           │
│   ├─ event bus (Map<event, Set<handler>>)             │
│   └─ BuilderInstance                                  │
│        ├─ creates <iframe sandbox="allow-scripts      │
│        │        allow-forms allow-same-origin">       │
│        ├─ window.addEventListener('message', …)       │
│        │     · validates event.source === iframe.cw   │
│        │     · validates event.origin === embed orig. │
│        │     · validates payload.type prefix          │
│        ├─ refresh scheduler (clears on destroy)       │
│        ├─ command dispatcher (postMessage with reqId  │
│        │     and Promise resolution by reqId)         │
│        └─ MutationObserver on container for cleanup   │
│                                                       │
└──────────────────────┬───────────────────────────────┘
                       │ postMessage protocol
┌──────────────────────▼───────────────────────────────┐
│         embed.craftkit.dev/builder?…                  │
│                                                       │
│  EmbedBridge (other side of the same protocol)        │
│    ├─ token validator                                 │
│    ├─ command handler (load, theme, preview, …)       │
│    └─ event emitter (ready, saved, published, …)      │
│                                                       │
│  ↓                                                    │
│  Builder UI (the same Tiptap editor as native,        │
│    but with catalog-aware variable picker, no chrome) │
│                                                       │
└───────────────────────────────────────────────────────┘
```

## Security guarantees

1. **Strict origin matching** — only accepts messages from `iframeOrigin`
   (default `https://embed.craftkit.dev`); silently drops everything else
2. **Source pinning** — `event.source === iframe.contentWindow` on every message
3. **Type prefix gate** — every event must start with `craftkit.`; foreign ignored
4. **Iframe sandboxed** — narrowest permissions that still let editor work
5. **Auto-cleanup** — when container element is removed from DOM, SDK detects
   via MutationObserver and tears down listeners (no memory leaks)
6. **No global pollution** — everything in returned instances; no `window.craftkit`

---
_Last revised: 2026-05-02_


---

<!-- doc:embed/builder -->
# Builder embed

Full reference for embedding the Craftkit template builder inside your SaaS so your customers can design, edit, and publish their own document templates — without leaving your product.

> **Before you start:** Enable embed mode for your project (Dashboard → Project → Embed → Overview → Enable embed mode) and make sure you have an API key minted in the target environment. See [Embed Quickstart](/documentation/embed/quickstart#before-you-start) for both prerequisites.

## Quick Start

**Mint a session (server-side)**
```bash
curl -X POST https://api.craftkit.dev/v1/embed/sessions \
  -H "Authorization: Bearer $CK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant": { "externalId": "org_42",    "displayName": "Acme Corp" },
    "actor":  { "externalId": "user_99",   "email": "ops@acme.com" },
    "scope":  { "mode": "edit", "templateExternalId": "charter-contract" }
  }'
```

Returns:
```json
{
  "session_id": "sess_01...",
  "session_token": "eyJ...",
  "iframe_url": "https://embed.craftkit.dev/embed/builder?session_token=eyJ...",
  "expires_at": "2026-05-03T10:30:00.000Z",
  "renew_token": "ert_..."
}
```

**Mount the builder (client-side)**
```javascript
import { Craftkit } from '@craftkit/embed';

const ck = Craftkit.init({ publishableKey: 'ck_pk_live_...' });

const builder = ck.mountBuilder({
  container: '#builder',
  sessionToken: sessionToken,
  autoResize: true,
  refresh: async () => {
    const r = await fetch('/api/craftkit/refresh', { method: 'POST' });
    return (await r.json()).session_token;
  },
});

builder.on('template.published', ({ templateId, version, manifest }) => {
  closeModal();
  showToast(`Template published (v${version})`);
});

builder.on('close.requested', () => builder.destroy());
```

---

## Scope modes

The `scope.mode` field on the session controls which surface loads and what the user can do.

| Mode | Iframe loads | When to use |
|---|---|---|
| `edit` | Builder with existing template | Customer edits a template they already created |
| `create` | Builder with blank canvas | Customer starts a brand-new template |
| `view` | Builder in read-only | Preview without editing (e.g. approval flows) |

For `edit` and `view`, supply either `scope.templateExternalId` (your stable identifier) or `scope.templateId` (Craftkit's internal UUID). For `create`, omit both — the builder creates a new template and returns the `templateId` in `template.published`.

```javascript
// Edit an existing template
{ "scope": { "mode": "edit", "templateExternalId": "charter-contract" } }

// Create a new template from scratch
{ "scope": { "mode": "create" } }

// Read-only preview
{ "scope": { "mode": "view", "templateExternalId": "charter-contract" } }
```

---

## Step 1 — Mint a session (server-side)

Never mint sessions in the browser — the secret API key must stay on your server.

### Full request shape

```typescript
POST https://api.craftkit.dev/v1/embed/sessions
Authorization: Bearer ck_live_...
Content-Type: application/json

{
  "tenant": {
    "externalId": "org_42",           // your stable org/account ID
    "displayName": "Acme Corp"        // shown in the Craftkit admin view
  },
  "actor": {
    "externalId": "user_99",          // your stable user ID within the tenant
    "email": "ops@acme.com",          // optional — audit trail only
    "displayName": "Ops Team"         // optional
  },
  "scope": {
    "mode": "edit",                   // "edit" | "create" | "view"
    "templateExternalId": "charter-contract"  // omit for mode=create
  },
  "permissions": {
    "publish":              true,
    "saveDraft":            true,
    "delete":               false,
    "rename":               false,
    "rollback":             false,
    "createCustomVariables": false,
    "changePageSettings":   true,
    "viewVersionHistory":   true
  },
  "branding": {
    "logoUrl": "https://acme.com/logo.svg",
    "primaryColor": "#2563EB",
    "locale": "en"
  },
  "callbacks": {
    "onPublished": "https://saas.acme.com/api/craftkit/events",
    "onCloseUrl":  "https://saas.acme.com/templates"
  },
  "limits": {
    "maxPublishes":   10,
    "maxSaveDrafts":  200,
    "maxUploadsBytes": 5242880
  },
  "catalogId": "cat_01..."   // optional — attach a variable catalog
}
```

### Permissions reference

| Permission | Default | Effect when `false` |
|---|---|---|
| `publish` | `false` | Publish button hidden; `triggerPublish()` returns `permission_denied` |
| `saveDraft` | `false` | Save button hidden; auto-save disabled |
| `delete` | `false` | Delete template option hidden |
| `rename` | `false` | Template title is read-only |
| `rollback` | `false` | Version history shown (if `viewVersionHistory: true`) but rollback disabled |
| `createCustomVariables` | `false` | Variable picker shows only catalog fields; "+" custom variable hidden |
| `changePageSettings` | `false` | Page size / margin settings locked |
| `viewVersionHistory` | `false` | Version history panel hidden entirely |

**Recommended defaults for a multi-tenant SaaS:**
- Enable `publish` and `saveDraft` — these are the core actions.
- Disable `delete` and `rename` unless you sync those events back to your DB.
- Disable `createCustomVariables` to keep templates locked to your data model.

### Response

```typescript
{
  "session_id":    "sess_01...",
  "session_token": "eyJ...",              // 5-minute JWT, pass to the iframe
  "iframe_url":    "https://embed.craftkit.dev/embed/builder?session_token=eyJ...",
  "expires_at":    "2026-05-03T10:30:00.000Z",
  "renew_token":   "ert_..."              // keep server-side for refresh
}
```

Store `renew_token` server-side. Return only `session_token` (and optionally `iframe_url`) to your frontend.

### Node.js helper

```javascript
async function mintBuilderSession({ orgId, userId, userEmail, templateExternalId }) {
  const res = await fetch('https://api.craftkit.dev/v1/embed/sessions', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CK_SECRET}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      tenant:  { externalId: orgId,    displayName: orgId },
      actor:   { externalId: userId,   email: userEmail },
      scope:   { mode: templateExternalId ? 'edit' : 'create', templateExternalId },
      permissions: { publish: true, saveDraft: true, viewVersionHistory: true },
    }),
  });
  if (!res.ok) {
    const { error } = await res.json();
    throw new Error(`${error.code}: ${error.message}`);
  }
  const { session_token, renew_token, expires_at } = await res.json();
  // store renew_token → your sessions store keyed by userId
  return { sessionToken: session_token, expiresAt: expires_at };
}
```

---

## Step 2 — Mount the builder (client-side)

### Using the SDK (recommended)

```bash
npm i @craftkit/embed
```

```javascript
import { Craftkit } from '@craftkit/embed';

const ck = Craftkit.init({
  publishableKey: 'ck_pk_live_...',
  debug: false,
});

const builder = ck.mountBuilder({
  container: '#builder-host',   // CSS selector or HTMLElement
  sessionToken,
  autoResize: true,             // resizes iframe to content height
  refresh: async () => {
    // Called automatically ~30s before token expiry
    const r = await fetch('/api/craftkit/refresh', { method: 'POST' });
    return (await r.json()).session_token;
  },
});
```

### Using a plain iframe

For frameworks that render iframes natively:

```html
<!-- Vanilla HTML -->
<iframe src="https://embed.craftkit.dev/embed/builder?session_token=..."
        style="width:100%;height:100%;border:0">
</iframe>
```

```tsx
// React
<iframe
  src={iframeUrl}
  style={{ width: '100%', height: '100%', border: 0 }}
/>
```

```vue
<!-- Vue -->
<iframe :src="iframeUrl" style="width:100%;height:100%;border:0" />
```

When not using the SDK you must handle session refresh and postMessage events manually. See [postMessage protocol](/documentation/embed/postmessage).

---

## Step 3 — Handle events

### Essential events

```javascript
// Iframe fully loaded and ready
builder.on('ready', ({ sessionId, templateId }) => {
  console.log('Builder ready, session:', sessionId);
});

// User published a version — this is your primary signal
builder.on('template.published', ({ templateId, version, manifest }) => {
  // templateId: Craftkit's UUID for the template
  // version: monotonic integer
  // manifest: { fields: [{ key, type, required }] } — the variable schema
  await saveTemplateToYourDB({ templateId, version });
  closeModal();
  showToast(`Published v${version}`);
});

// User saved a draft (not published)
builder.on('template.saved', ({ templateId, version, manifest }) => {
  setDraftIndicator(`Draft v${version} saved`);
});

// User clicked the close button
builder.on('close.requested', () => {
  builder.destroy();
  closeModal();
});

// Any error surface
builder.on('error', (err) => {
  console.error('Craftkit error:', err.code, err.message);
  if (!err.recoverable) showFallbackUI();
});
```

### Session events

```javascript
builder.on('session.expiring', ({ secondsRemaining }) => {
  // The SDK calls refresh() automatically if you provided it.
  // This event is for your own UI feedback if needed.
  console.log(`Session expiring in ${secondsRemaining}s`);
});

builder.on('session.refreshed', ({ newExpiresAt }) => {
  console.log('Session renewed until', newExpiresAt);
});

builder.on('session.expired', () => {
  // Only fires if refresh failed or wasn't provided.
  builder.destroy();
  showError('Session expired. Please reopen the editor.');
});
```

### Variable events

```javascript
builder.on('variable.inserted', ({ key, type, source }) => {
  // source: 'catalog' | 'custom'
  updateVariableList(key, type);
});

builder.on('variable.removed', ({ key }) => {
  removeFromVariableList(key);
});
```

### Full event reference

| Event | Payload | Notes |
|---|---|---|
| `ready` | `{ sessionId, templateId }` | `templateId` is `null` for `mode=create` until first save |
| `template.saved` | `{ templateId, version, manifest }` | Draft — not yet published |
| `template.published` | `{ templateId, version, manifest }` | Triggers your webhook too |
| `variable.inserted` | `{ key, type, source }` | After a variable node is dropped in |
| `variable.removed` | `{ key }` | After removal |
| `session.expiring` | `{ secondsRemaining }` | ~30s before JWT exp |
| `session.refreshed` | `{ newExpiresAt }` | After token replacement |
| `session.expired` | `{}` | Refresh failed or was not provided |
| `height.changed` | `{ heightPx }` | For manual iframe sizing when `autoResize: false` |
| `close.requested` | `{}` | User clicked close — you control what happens |
| `error` | `{ code, message, recoverable }` | See error codes below |

**Error codes:**

| Code | Recoverable | Cause |
|---|---|---|
| `session_invalid` | No | JWT failed validation (wrong env, expired at load, embed not enabled) |
| `session_expired` | No | TTL elapsed and no refresh was provided |
| `origin_not_allowed` | No | Parent origin not in the allowed-origins list |
| `permission_denied` | Yes | Action attempted without the required permission |
| `iframe_load_failed` | Yes | Network error loading the iframe |
| `refresh_failed` | No | `refresh()` threw or returned an invalid token |
| `rate_limited` | Yes | Too many requests |
| `unknown` | Maybe | Unexpected server error |

---

## Imperative commands

You can drive the builder programmatically in addition to listening for events.

```javascript
// Programmatically save the current state
const { version } = await builder.triggerSave();
console.log('Saved as draft v' + version);

// Programmatically publish
const { version } = await builder.triggerPublish();
console.log('Published v' + version);

// Switch to a different template mid-session (same session token)
await builder.loadTemplate('other-template-external-id');

// Inject preview data so variables resolve in the live preview
builder.setPreviewData({
  'customer.name': 'Ada Lovelace',
  'booking.date': '2026-06-15',
});

// Update theme without remounting
builder.setTheme({ primaryColor: '#FF6600', logoUrl: 'https://...' });

// Focus the editor (e.g. after user clicks a surrounding UI element)
builder.focus();

// Tear down and clean up all listeners
builder.destroy();
```

---

## Session refresh

Sessions expire after 5 minutes. The SDK handles renewal automatically if you supply a `refresh` callback. The renewal flow:

1. Iframe emits `craftkit.session.expiring` (~30s before exp)
2. SDK calls your `refresh()` function
3. Your frontend hits your backend: `POST /api/craftkit/refresh`
4. Your backend calls `POST https://api.craftkit.dev/v1/embed/sessions/refresh`
5. Craftkit returns a new `session_token` (and rotates the `renew_token`)
6. SDK sends the new token to the iframe via postMessage

**Backend refresh endpoint:**
```javascript
app.post('/api/craftkit/refresh', async (req, res) => {
  const renewToken = getRenewTokenForUser(req.user.id);  // from your DB
  const r = await fetch('https://api.craftkit.dev/v1/embed/sessions/refresh', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CK_SECRET}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ renewToken }),
  });
  const { session_token, renew_token } = await r.json();
  updateRenewTokenForUser(req.user.id, renew_token);  // rotate in your DB
  res.json({ session_token });
});
```

> `renew_token` is single-use. Rotate it on every successful refresh or it becomes invalid.

---

## The template creation lifecycle

Understanding this lifecycle is important for syncing Craftkit's state with your own database.

```
mode=create                              mode=edit
     │                                       │
     ▼                                       ▼
[Blank canvas]                    [Existing template loaded]
     │                                       │
     │   User designs the template           │   User edits
     ▼                                       ▼
[template.saved]  ←─────────────────── [template.saved]
     │  version increments (draft)           │
     ▼                                       ▼
[template.published] ──────────────── [template.published]
     │  version increments                   │
     │  webhook fires                        │  webhook fires
     ▼                                       ▼
  Your DB ◄──── store templateId + version ──── Your DB
```

**Key rules:**
- `templateId` is only available after the first `template.saved` or `template.published` event (for `mode=create`, `templateId` is `null` in the `ready` event).
- Drafts never trigger a webhook — only publishes do.
- The `manifest` in the event payload contains the live variable schema. Use it to validate downstream render calls.
- Version numbers are monotonic integers. A new publish always increments.

### Connecting template.published to your own data

```javascript
builder.on('template.published', async ({ templateId, version, manifest }) => {
  // 1. Store the mapping in your database
  await db.upsert('craftkit_templates', {
    externalId: currentTemplateExternalId,
    craftkitTemplateId: templateId,
    currentVersion: version,
    variables: manifest.fields,
  });

  // 2. Use the manifest to pre-validate render data
  const requiredKeys = manifest.fields.filter(f => f.required).map(f => f.key);
  console.log('Required fields for render:', requiredKeys);

  // 3. Close the editor
  builder.destroy();
  router.push(`/templates/${currentTemplateExternalId}`);
});
```

---

## Branding the builder

Branding can be set at session mint time (static), sent at runtime via `builder.setTheme()`, or updated via the dashboard's Themes page.

### At session mint

```json
{
  "branding": {
    "logoUrl":      "https://yourapp.com/logo.svg",
    "primaryColor": "#2563EB",
    "locale":       "en",
    "ui": {
      "showTopBar":          false,
      "showTemplateList":    false,
      "showRenderHistory":   false,
      "showApiKeysLink":     false,
      "showPublishButton":   true,
      "showSaveDraftButton": true,
      "showCloseButton":     true
    }
  }
}
```

### At runtime

```javascript
builder.setTheme({
  primaryColor: '#FF6600',
  logoUrl: 'https://yourapp.com/logo-dark.svg',
});
```

For advanced theming (CSS custom properties, font injection, density, layout), see [Styling & themes](/documentation/embed/styling).

---

## Attaching a variable catalog

A variable catalog injects your data model into the template builder so users see your fields (customer name, booking date, etc.) in the variable picker.

**Publish the catalog first (once, from CI/CD):**
```bash
curl -X POST https://api.craftkit.dev/v1/embed/catalogs \
  -H "Authorization: Bearer $CK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acme-default",
    "catalog": {
      "allowCustom": false,
      "namespaces": [{
        "key": "customer", "label": "Customer",
        "fields": [
          { "key": "customer.name",  "label": "Name",  "dataType": "text" },
          { "key": "customer.email", "label": "Email", "dataType": "email" }
        ]
      }],
      "loops": []
    }
  }'
# → { "id": "cat_01...", "name": "acme-default", "version": 1 }
```

**Reference in the session:**
```json
{
  "catalogId": "cat_01...",
  "scope": { "mode": "edit", "templateExternalId": "charter-contract" }
}
```

See [Variable catalog](/documentation/embed/variable-catalog) for the full field schema and [POST /v1/embed/catalogs](/documentation/api/embed-catalogs) for the API reference.

---

## Webhooks

postMessage events (`.published`, `.saved`) are low-latency but not durable — they're lost if the tab closes. Webhooks are the reliable, server-to-server source of truth.

Configure the webhook URL in `callbacks.onPublished` at session mint or in the dashboard.

**Payload:**
```json
{
  "type": "template.published",
  "templateId": "ck_tpl_01...",
  "version": 3,
  "manifest": { "fields": [{ "key": "customer.name", "type": "text", "required": true }] },
  "tenantExternalId": "org_42",
  "actorExternalId": "user_99",
  "timestamp": "2026-05-03T10:28:00.000Z"
}
```

**Verifying the signature:**
```javascript
import { createHmac } from 'crypto';

function verifyWebhook(rawBody, signatureHeader, secret) {
  const expected = createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  return `sha256=${expected}` === signatureHeader;
}

app.post('/api/craftkit/events', (req, res) => {
  const sig = req.headers['x-craftkit-signature'];
  if (!verifyWebhook(req.rawBody, sig, process.env.CK_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'invalid_signature' });
  }
  const event = JSON.parse(req.rawBody);
  if (event.type === 'template.published') {
    await handlePublish(event);
  }
  res.json({ received: true });
});
```

---

## Error handling

**`invalid_credentials` on session mint:** The API key was created in a different environment, or embed is not enabled for the project. See [Authentication troubleshooting](/documentation/api/authentication#troubleshooting-invalid_credentials).

**`error` event with `session_invalid`:** The session token failed server-side validation. Most common causes: embed not enabled, key from wrong environment, or the token expired before the iframe loaded. Mint a fresh session.

**`error` event with `origin_not_allowed`:** The parent page's origin is not in the allowed-origins list. Add it via Dashboard → Project → Embed → Origins.

```javascript
builder.on('error', (err) => {
  if (!err.recoverable) {
    // Show a fallback UI — the builder cannot continue
    showFallback(`Editor unavailable: ${err.message}`);
    builder.destroy();
  }
  // Recoverable errors (permission_denied, rate_limited) can be surfaced inline
});
```

---

## Related

- [Embed quickstart](/documentation/embed/quickstart) — builder + form setup in five minutes
- [Form-fill embeddable](/documentation/embed/form-route) — let end-users fill published templates
- [Variable catalog](/documentation/embed/variable-catalog) — inject your data model into the picker
- [Styling & themes](/documentation/embed/styling) — brand the builder for your customers
- [JWT spec](/documentation/embed/jwt) — session token shape and signing
- [postMessage protocol](/documentation/embed/postmessage) — raw event bus reference
- [Host SDK](/documentation/embed/sdk) — full SDK API surface
- [POST /v1/embed/catalogs](/documentation/api/embed-catalogs) — publish catalogs from CI/CD
- [Authentication](/documentation/api/authentication) — API key setup and troubleshooting


---

<!-- doc:embed/form-route -->
# 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'`:

```jsonc
"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:

```jsonc
"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`:

```jsonc
"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`:

```ts
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)

```js
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:

```sql
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`:

```js
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:

```js
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 `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:

```jsonc
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

```ts
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`.


---

<!-- doc:embed/variable-catalog -->
# 04 — Variable Catalog

The variable catalog is the **innovation** that lets partners inject their
data model into the embedded builder without sharing schemas.

> The catalog is also consumed by the **form-fill embed** (`/embed/form`)
> to drive auto-form generation: each `CatalogField` becomes one input,
> field labels and namespaces become section headings, and `previewData`
> doubles as the input's placeholder so the end-user sees the expected
> shape. When no catalog is attached to a session, the form embed falls
> back to the published version's `variableManifest`. See
> [12-form-route.md](./12-form-route.md) §6.

## What a catalog is

A typed, namespaced directory of variables that the partner makes available
to their tenants in the embed builder.

```jsonc
{
  "name": "partner-default",
  "version": 3,
  "allow_custom": false,
  "namespaces": [
    {
      "key": "customer",
      "label": "Customer",
      "icon": "user",
      "fields": [
        { "key": "customer.name",  "label": "Customer name",  "dataType": "text" },
        { "key": "customer.email", "label": "Customer email", "dataType": "email" }
      ]
    },
    {
      "key": "order",
      "label": "Order",
      "icon": "package",
      "fields": [
        { "key": "order.id",         "label": "Order ID",      "dataType": "text" },
        { "key": "order.placed_at",  "label": "Placed",        "dataType": "date", "format": "date:DD/MM/YYYY" },
        { "key": "order.total",      "label": "Total",         "dataType": "currency", "format": "currency:EUR" }
      ]
    }
  ],
  "loops": [
    {
      "key": "order.items",
      "label": "Items",
      "item_fields": [
        { "key": "name", "label": "Product name", "dataType": "text" },
        { "key": "qty",  "label": "Quantity",     "dataType": "number" },
        { "key": "price","label": "Unit price",   "dataType": "currency" }
      ]
    }
  ]
}
```

## How catalogs flow

```
1. Partner defines a catalog in their admin (or builds it dynamically)
2. Partner POSTs catalog inline OR by name when minting a session
3. Craftkit stores the catalog under cat_… and references it in JWT
4. Embed page fetches catalog at load
5. Variable picker UI renders the catalog as a tree
6. User clicks/drags a field → variable node inserted with attrs from catalog
7. The chip's appearance includes the namespace breadcrumb
```

## Inline vs Named catalogs

### Inline (smaller catalogs, dynamic)

```http
POST /v1/embed/sessions
{
  "tenant": {...},
  "actor": {...},
  "variable_catalog": { ... full inline ... }
}
```

Use when:
- Catalog varies per tenant (e.g., custom fields)
- Catalog is small (<50 fields)
- Catalog is computed dynamically per session

### Named (large catalogs, stable)

```http
POST /v1/embed/sessions
{
  "tenant": {...},
  "actor": {...},
  "catalog_ref": "partner-default-v3"
}
```

Use when:
- Catalog is stable across all tenants (or all in a tier)
- Catalog is large (>50 fields)
- Partner manages catalogs through admin UI

## Catalog evolution

Catalogs are **versioned**, never mutated in place. Every change creates a
new version (v1, v2, v3, …).

When the catalog changes, three scenarios:

| Scenario | Behavior |
|---|---|
| **Field added** | Templates keep working; new field appears in picker on next session |
| **Field renamed** (label changed, key stable) | Existing chips re-render with new label automatically |
| **Field removed** (key gone) | Chips for missing keys render as `[?  customer.legacy_field]` with a warning style; preview shows `(missing)`; publishing is blocked until resolved |
| **Field key changed** | Treated as remove + add. UI shows a one-click "Remap to new field" suggestion if labels match closely |

The remap UX is **critical** for long-term data model evolution and is what
separates a polished embed integration from a brittle one.

## The catalog-aware variable picker

The picker has **three layouts**, all over the same data:

### Layout A: Popover (toolbar trigger)

Popover with search + tree. Recently-used at top. Each row shows type glyph,
label (middle), key (monospace, faded, right). Required fields show `●`.

### Layout B: Always-visible left rail (premium UX)

The killer move for embed mode. Catalog tree always visible on the left.
Drag-to-insert + click-to-insert. Inside a loop, rail context-switches to
that loop's `item_fields`.

### Layout C: Slash menu (inline)

User types `/` → inline menu with both variables and formatting commands.
Notion-style.

## Field type catalog

Every field has a `dataType`. Supported types:

| Type | Renders as | Example value |
|---|---|---|
| `text` | inline text | "John Doe" |
| `longtext` | block text | (paragraph) |
| `number` | number | `42` |
| `currency` | formatted currency | "€42.00" |
| `date` | formatted date | "02/05/2026" |
| `boolean` | "Yes" / "No" | true |
| `image` | <img> | (url) |
| `url` | <a> | (url) |
| `email` | <a mailto:> | "x@y.com" |

Format strings (`format: "currency:EUR"`, `format: "date:DD/MM/YYYY"`) are
optional helpers applied at render time.

## Catalog namespacing convention

- Top-level keys are namespaces: `customer`, `order`, `booking`
- Dot-paths reference fields: `customer.name`, `order.total`
- Loops use the same dot-path pattern: `order.items`
- Inside a loop, item fields are unprefixed: `name`, `qty`, `price`
- Compiled Handlebars: `{{customer.name}}`, `{{#each order.items}}{{name}}{{/each}}`

---
_Last revised: 2026-05-02_


---

<!-- doc:embed/admin-ui -->
# 07 — Embed Admin UI

The partner-facing surface for managing their embed integration.

Lives at `app.craftkit.dev/dashboard/{projectSlug}/embed/*` once the project
is in embed-partner mode.

## Information architecture

```
Project › Embed
  ├─ Overview                  (status + setup checklist + activity)
  ├─ Configuration
  │   ├─ Keys                  (publishable, secret, signing)
  │   ├─ Allowed Origins       (CSP + CORS)
  │   ├─ Catalogs              (named, versioned, diffable)
  │   ├─ Permission Presets    (admin/editor/viewer roles)
  │   └─ Embed Webhooks        (separate from regular webhooks)
  ├─ Sessions                  (live console + session inspector)
  ├─ Usage & Billing
  │   ├─ This period
  │   ├─ Tenants               (per-tenant breakdown)
  │   ├─ Bill-back attribution
  │   ├─ Plans & limits
  │   └─ Invoices
  └─ Test Embed                (sandbox to play with the integration)
```

## Enablement (first visit)

`Embed` section is disabled by default. First visit shows:

```
┌──────────────────────────────────────────────────────────────────┐
│ Embed Craftkit in your product                                   │
│                                                                  │
│ Let your customers build templates inside your own UI, with     │
│ variables that map to your data model.                           │
│                                                                  │
│   ✦  Drop-in iframe builder                                      │
│   ✦  Inject your variable catalog                                │
│   ✦  Webhook callbacks on publish                                │
│   ✦  Per-tenant branding & permissions                           │
│                                                                  │
│ Pricing: included on Scale and above. Counts toward your render │
│ quota. View pricing →                                            │
│                                                                  │
│ [Enable Embed for this project]   [Read the integration docs]   │
└──────────────────────────────────────────────────────────────────┘
```

## Overview screen

- Setup checklist (5 items: keys, origins, catalog, webhook, first session)
- Live activity sparkline (sessions per hour, last 24h)
- Recent events feed (last 10)
- Quick-start code snippet (one curl)
- "Test embed" button (opens sandbox modal)

## Configuration → Keys

Three keypairs:

| Type | Visibility | Use |
|---|---|---|
| **Publishable** (`ck_pk_*`) | Always shown | Browser-safe identifier |
| **Secret** (`ck_live_*`) | Shown ONCE on creation | Server-side only |
| **Signing keys** (Ed25519 keypair) | Public key always shown | Optional: partners verify JWTs themselves |

All keys rotatable. Secret keys: support multiple active (Production + Staging),
each with name + last-used + revoke. Signing keys: 24h overlap on rotation.

## Configuration → Allowed Origins

```
┌──────────────────────────────────────────────────────────────────┐
│  Allowed origins                                                 │
│                                                                  │
│  Origin                          Environment    Added            │
│  ────────────────────────────────────────────────────────────── │
│  https://app.kleesto.com         Production     Apr 3   ✕       │
│  https://staging.kleesto.com     Staging        Apr 3   ✕       │
│  http://localhost:3000           Development    Apr 3   ✕       │
│                                                                  │
│  [+ Add origin]   Wildcards: https://*.partner.com               │
│                                                                  │
│  CSP snippet:                                                    │
│   frame-src https://embed.craftkit.dev;                          │
│   connect-src https://api.craftkit.dev;                          │
│                                                                  │
│  ⚠  Recent denials (last 24h)                                    │
│   ◾ 2 attempts from https://evil.example.com — blocked           │
│      [Add to allowlist]  [Mark as expected]  [Ignore]            │
└──────────────────────────────────────────────────────────────────┘
```

## Configuration → Catalogs

Named, versioned catalogs. Tree editor with diff/remap tooling.

```
┌──────────────────────────────────────────────────────────────────┐
│  ▾  partner-default                       v3 · current           │
│      62 fields · 4 namespaces · 2 loops                          │
│      Used in 1,205 sessions this month                           │
│      [Edit]  [View JSON]  [Diff vs v2]  [Test in sandbox]        │
│                                                                  │
│  ▸  partner-eu                            v1 · current           │
│  ▸  partner-default                       v2 · archived          │
└──────────────────────────────────────────────────────────────────┘
```

Edit view: tree of namespaces → fields, plus diff vs previous version showing
add/remove/rename, with remap suggestions when keys change.

## Configuration → Permission Presets

Named role bundles (admin, editor, viewer) with permission flags. Backend
sends `permissions_preset: "editor"` instead of 8 individual flags.

## Sessions

Live console listing currently-active sessions + recent (last 7d) sessions.

Each row: session id, tenant, actor, template, duration, outcome icon.

Click a session → **session inspector** with full lifecycle:
- Created at + IP
- Token issued + exp
- Iframe loaded
- Editor ready
- Each variable inserted (with timestamp + source)
- Token refreshes
- Save/publish events
- Webhook deliveries (with response status)
- Errors

Plus [Revoke] button — invalidates all tokens immediately.

## Test Embed sandbox

```
┌──────────────────────────────────────────────────────────────────┐
│  Test embed                                              [✕]    │
│                                                                  │
│  Mock a session and play with the embedded builder right here. │
│                                                                  │
│  Catalog:    [ partner-default v3 ▾ ]                            │
│  Permissions: [ editor ▾ ]                                       │
│  Tenant:     [ Mock Tenant Ltd ▾ ] [+ custom]                    │
│  Branding:   [ ⬛ #B7541C ] [Logo: …]  [ Locale: en-GB ▾ ]       │
│                                                                  │
│  ┌────────────────────────────────────────────────────────────┐ │
│  │  [ Embedded Craftkit builder mounted here, fully working ] │ │
│  │  [ Same iframe, same JWT flow, same catalog injection    ] │ │
│  └────────────────────────────────────────────────────────────┘ │
│                                                                  │
│  Event log                                                       │
│   14:32:11  craftkit.ready                                       │
│   14:32:24  craftkit.variable.inserted  customer.name            │
│   14:32:38  craftkit.template.saved     v1                       │
│   14:32:55  craftkit.template.published v1                       │
│                                                                  │
│  [Open in new tab]  [Copy session URL]  [Reset]                  │
└──────────────────────────────────────────────────────────────────┘
```

This single button removes 90% of integration friction.

## Embed Webhooks (separate from regular webhooks)

Embed lifecycle events:
- `embed.session.created` / `refreshed` / `expired` / `revoked`
- `embed.template.saved` / `published`
- `embed.variable.inserted` (high-volume, opt-in)
- `embed.security.origin_blocked` / `permission_denied`
- `embed.feedback.submitted`
- `embed.quota.exhausted`

Retry: exponential backoff 1s → 1024s, max 8 attempts. Idempotency via
`x-craftkit-event-id` header (24h dedupe window).

---

## Stable `.ck-*` class catalogue (styling contract)

Every class below is a **versioned API surface**. Partners may target it
via `appearance.rules` or via a `stylesheetUrl` that loads inside the
iframe. Adding a class is non-breaking; renaming, removing, or
restructuring its DOM nesting is a breaking change requiring a major
version bump of the embed.

### Root + chrome

| Class | DOM context |
|---|---|
| `.ck-embed-root`         | Outermost wrapper. Holds `data-theme`, `data-density`, `lang`. CSS variables live here. |
| `.ck-embed-shell`        | Two-pane shell: catalog rail + canvas. |
| `.ck-embed-canvas-wrap`  | Scrollable canvas pane. |
| `.ck-embed-banner`       | Inline notification banner above the canvas. |
| `.ck-embed-banner-warn`  | Warning variant of banner. |
| `.ck-embed-error`        | Full-iframe error state. |
| `.ck-embed-error-card`   | The card inside the error state. |
| `.ck-embed-error-icon`   | Icon glyph in the error card. |
| `.ck-embed-error-ref`    | Reference id displayed under the error message. |

### Catalog rail (variable picker)

| Class | DOM context |
|---|---|
| `.ck-catalog-rail`              | Left rail container. |
| `.ck-catalog-rail-title`        | Section heading inside the rail. |
| `.ck-catalog-search`            | Search input wrapper. |
| `.ck-catalog-namespace`         | A grouping (e.g. "Customer", "Order"). |
| `.ck-catalog-namespace-header`  | Header row of a namespace. |
| `.ck-catalog-namespace-count`   | Badge with the field count. |
| `.ck-catalog-field`             | Draggable field row. |
| `.ck-catalog-field-type`        | Type-glyph badge (T/N/D/…). |
| `.ck-catalog-field-label`       | Field label text. |
| `.ck-catalog-field-key`         | Monospace key path next to the label. |
| `.ck-catalog-field-req`         | Required marker. |

### Toolbar / actions

| Class | DOM context |
|---|---|
| `.ck-toolbar`           | Toolbar container. |
| `.ck-toolbar-section`   | Group inside the toolbar. |
| `.ck-publish-button`    | The publish CTA — shown unless `layout.showPublishButton: false`. |
| `.ck-save-hint`         | "Saved …" hint next to publish. |

### Block palette + canvas

| Class | DOM context |
|---|---|
| `.ck-block-palette`         | Right-side block insert panel. |
| `.ck-block-palette-button`  | A single insert button (Heading / Text / …). |
| `.ck-canvas`                | Page canvas root. |
| `.ck-page`                  | A single page. |
| `.ck-section`               | A page section. |
| `.ck-row`                   | A row inside a section. |
| `.ck-block`                 | Generic block wrapper. |
| `.ck-block-text`, `.ck-block-heading`, `.ck-block-paragraph`, `.ck-block-variable`, `.ck-block-image`, `.ck-block-signature` | Block-type variants. |

### Inspector + form primitives

| Class | DOM context |
|---|---|
| `.ck-inspector`         | Right rail when a block is selected. |
| `.ck-inspector-field`   | A single form row in the inspector. |
| `.ck-input`             | Text input / textarea base. |
| `.ck-input-invalid`     | Invalid input state. |
| `.ck-button`            | Button base — modifiers below. |
| `.ck-button-primary`    | Filled brand button. |
| `.ck-button-secondary`  | Outlined neutral button. |
| `.ck-button-ghost`      | Hover-only button. |
| `.ck-prose`             | Rich-text rendering target inside Tiptap blocks. |

### State suffixes accepted by `rules`

`:hover`, `:focus`, `:focus-visible`, `:active`, `:disabled`,
`::placeholder`, `--selected`, `--invalid`. Anything else dropped.

See [11-styling.md](./11-styling.md) for the full styling contract.

---
_Last revised: 2026-05-03_


---

<!-- doc:embed/failure-modes -->
# 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:

```ts
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_


---

<!-- doc:embed/sessions-api -->
# Session API (mint & refresh)

Mint a short-lived embed session your front-end can load in an iframe. The partner backend calls this with its project API key; the response carries a signed session JWT, the iframe URL to mount, and a single-use renew token. Refresh rotates that token before the session expires.

```http
POST /v1/embed/sessions
POST /v1/embed/sessions/refresh
```

Both endpoints authenticate with a project API key (`Authorization: Bearer ck_live_…`). The key's project must have embed enabled (an embed partner row), or auth fails with `invalid_credentials`. Sessions live for 4 hours.

## Quick Start

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/embed/sessions \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant": { "externalId": "org_123", "displayName": "Acme Corp" },
    "actor":  { "externalId": "usr_456", "displayName": "Jane Smith", "email": "jane@acme.com" },
    "scope":  { "mode": "edit", "templateExternalId": "invoice" },
    "catalogRef": { "name": "my-catalog" },
    "permissions": { "publish": true, "saveDraft": true }
  }'
```

**Node.js**
```typescript
const res = await fetch('https://api.craftkit.dev/v1/embed/sessions', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    tenant: { externalId: 'org_123', displayName: 'Acme Corp' },
    actor: { externalId: 'usr_456', displayName: 'Jane Smith', email: 'jane@acme.com' },
    scope: { mode: 'edit', templateExternalId: 'invoice' },
    catalogRef: { name: 'my-catalog' },
    permissions: { publish: true, saveDraft: true },
  }),
});

const { iframe_url, renew_token, expires_at } = await res.json();
// Mount iframe_url in an <iframe>; persist renew_token to rotate before expires_at.
```

**Python**
```python
import os, requests

res = requests.post(
    "https://api.craftkit.dev/v1/embed/sessions",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
    json={
        "tenant": {"externalId": "org_123", "displayName": "Acme Corp"},
        "actor": {"externalId": "usr_456", "displayName": "Jane Smith", "email": "jane@acme.com"},
        "scope": {"mode": "edit", "templateExternalId": "invoice"},
        "catalogRef": {"name": "my-catalog"},
        "permissions": {"publish": True, "saveDraft": True},
    },
)
session = res.json()
```

## Request body

```json
{
  "tenant": {
    "externalId": "org_123",
    "displayName": "Acme Corp",
    "branding": { "primaryColor": "#0F62FE" }
  },
  "actor": {
    "externalId": "usr_456",
    "displayName": "Jane Smith",
    "email": "jane@acme.com",
    "avatarUrl": "https://acme.com/avatars/jane.png"
  },
  "scope": { "mode": "edit", "templateExternalId": "invoice", "initialName": "New invoice" },
  "catalogRef": { "name": "my-catalog", "version": 2 },
  "permissions": { "publish": true, "saveDraft": true, "delete": false },
  "permissionsPreset": "editor",
  "branding": { "primaryColor": "#0F62FE", "logoUrl": "https://acme.com/logo.svg" },
  "appearance": { "baseTheme": "light", "variables": { "colorPrimary": "#0F62FE" } },
  "callbacks": { "onPublishedUrl": "https://acme.com/hooks/published" },
  "limits": { "maxPublishes": 10 },
  "form": { "showPreview": true, "prefill": { "customer.name": "Acme Corp" }, "captureMode": "render" }
}
```

| Field | Type | Description | Default |
|---|---|---|---|
| `tenant` | object | The organization the session belongs to. Upserted on every mint. Required. | — |
| `tenant.externalId` | string | Your stable id for the org (1–160 chars). Required. | — |
| `tenant.displayName` | string | Org name shown in the embed (1–200 chars). Required. | — |
| `tenant.branding` | object | Optional partial branding override scoped to this tenant. | — |
| `actor` | object | The end-user inside the iframe. Upserted under the tenant. Required. | — |
| `actor.externalId` | string | Your stable id for the user (1–160 chars). Required. | — |
| `actor.displayName` | string | User's display name (≤200 chars). | — |
| `actor.email` | string | User email (validated). | — |
| `actor.avatarUrl` | string | User avatar URL (validated). | — |
| `scope` | object | What the session can open. | `{ "mode": "edit" }` |
| `scope.mode` | string | `edit`, `create`, `view`, or `fill`. `fill` mounts the form route; the others mount the builder. | `edit` |
| `scope.templateExternalId` | string | Your id for the template to load (≤200 chars). Resolved to a Craftkit template id. | — |
| `scope.initialName` | string | Initial name for new templates (`create` mode). Ignored when loading an existing template. | — |
| `variableCatalog` | object | An **inline** catalog to use for this session only (see [Publish a catalog](/documentation/api/embed-catalogs) for the shape). Mutually exclusive with `catalogRef`. | — |
| `catalogRef` | object | Reference a **published** catalog by `name` (+ optional `version`). Resolves to the current version when `version` is omitted. | — |
| `permissions` | object | Partial override of the permission flags (`publish`, `saveDraft`, `delete`, `rename`, `rollback`, `createCustomVariables`, `changePageSettings`, `viewVersionHistory`, `submitForm`, `saveFormDraft`, `shareDocument`, `emailDocument`, `viewEngagement`). Omitted flags fall back to schema defaults. | schema defaults |
| `permissionsPreset` | string | Name of a saved permission preset (≤60 chars). Accepted by the schema; reserved. | — |
| `branding` | object | Partial branding (`primaryColor`, `logoUrl`, `fontUrl`, `locale`, `ui`, `support`). | locale `en` |
| `appearance` | object | Framework-agnostic styling contract (`baseTheme`, `variables`, `rules`, `layout`, `stylesheetUrl`, `fontUrl`, `logoUrl`). Supersedes `branding` when both are present. | partner default theme |
| `callbacks` | object | `onPublishedUrl` / `onCloseUrl` — partner URLs the embed posts to. | — |
| `limits` | object | Partial override of `maxPublishes` (10), `maxSaveDrafts` (200), `maxUploadsBytes` (5 MiB). | shown defaults |
| `form` | object | Form-fill claims — only meaningful when `scope.mode === 'fill'`: `prefill`, `showPreview` (false), `showDocumentAfterSubmit` (true), `redirectUrl`, `captureMode` (`render`). | — |
| `form.captureMode` | string | `render` (default) creates a render on submit and returns a render envelope — today's behavior. `collect` makes the form data-collection only: submit validates the data and emits a [`form.submitted`](/documentation/api/webhooks) webhook to the project's subscribers **without creating a render or storing the field data**; you persist the data and request the render as a separate call. Requires an active webhook subscribed to `form.submitted`. `showDocumentAfterSubmit` is not applicable in `collect` mode. | `render` |

> **Collect-only capture.** `form.captureMode: "collect"` is opt-in per fill session and is designed for partners who are the system of record and don't want Craftkit to retain the submitted field data. Because nothing is stored, the `form.submitted` webhook is the only delivery path (no pull fallback) — the payload is held until you `2xx` it, then purged. See [Webhooks → `form.*`](/documentation/api/webhooks). The `captureMode` is persisted server-side, so a token refresh keeps a `collect` session `collect`. `prefill` is not persisted across refresh (your client already holds those values).

> **Catalog: inline vs reference.** Send **either** `variableCatalog` (a one-off inline catalog) **or** `catalogRef` (a pointer to a published catalog). If you send neither, the session has no catalog. `catalogRef.name` must already be published to this project — an unknown name returns `404 catalog_not_found`. See [Publish a catalog](/documentation/api/embed-catalogs).

## Response

```json
{
  "session_id": "0193c2c3-1a2b-7c3d-8e4f-aabbccddeeff",
  "session_token": "eyJhbGciOiJFZERTQS...",
  "iframe_url": "https://embed.craftkit.dev/embed/builder?session_token=eyJhbGciOiJFZERTQS...",
  "expires_at": "2026-06-05T14:00:00.000Z",
  "renew_token": "ert_8sR2...Xq"
}
```

| Field | Type | Description |
|---|---|---|
| `session_id` | string | Session UUID. Use it to revoke the session server-side. |
| `session_token` | string | Signed EdDSA JWT. Carried as `?session_token=` in `iframe_url`; do not expose it to the wrong origin. |
| `iframe_url` | string | The URL to mount in your `<iframe>`. Points at the builder (or the form route in `fill` mode). |
| `expires_at` | string | ISO-8601 expiry, 4 hours from mint. Call refresh before this. |
| `renew_token` | string | Single-use token for `POST /v1/embed/sessions/refresh`. Each refresh returns a new one; the old one is invalidated. |

## Refresh — `POST /v1/embed/sessions/refresh`

Rotate a session's token before it expires. The `renewToken` is single-use: each call mints a fresh token, extends `expires_at` by 4 hours, and returns a new `renew_token` (the old one stops working).

```bash
curl -X POST https://api.craftkit.dev/v1/embed/sessions/refresh \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "renewToken": "ert_8sR2...Xq" }'
```

| Field | Type | Description | Default |
|---|---|---|---|
| `renewToken` | string | The `renew_token` from the last mint or refresh (min 8 chars). Required. | — |

The response shape is identical to the mint response (`session_id`, `session_token`, `iframe_url`, `expires_at`, `renew_token`).

> **Same project's key required.** Refresh looks the session up by `(partnerId, renewToken)`. You must call it with an API key from the **same project** that minted the session — a valid key from a different project will not find the session and returns `401 refresh_failed`. A renew token that was already rotated, or a session that is no longer active, also returns `401 refresh_failed`.

## Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 401 | `missing_authorization` | No `Authorization: Bearer` header | Send the project API key |
| 401 | `invalid_credentials` | Key not found, revoked, or embed not enabled for the project | Check the key and that embed is enabled |
| 400 | `invalid_json` | Body wasn't valid JSON | Check `Content-Type` and JSON.stringify |
| 422 | `invalid_request` | Body failed schema validation (mint includes `issues`) | See the request body table above |
| 404 | `catalog_not_found` | `catalogRef.name` has no current catalog in this project | Publish the catalog first, or fix the name |
| 401 | `refresh_failed` | (refresh) Renew token invalid, already rotated, session inactive, or wrong project's key | Re-mint, or use the minting project's key |
| 500 | `catalog_resolution_failed` | Inline catalog or `catalogRef` lookup threw server-side | Retry; check the catalog payload |
| 500 | `mint_failed` | Session minting threw (e.g. partner has no active signing key) | Retry; contact support if it persists |

## Related

- [Publish a catalog](/documentation/api/embed-catalogs) — define the variable picker tree referenced by `catalogRef`
- [Multi-tenant setup](/documentation/embed/multi-tenant) — refresh with the correct per-org key
- [Errors](/documentation/api/errors) — error envelope and retry semantics
- [Authentication](/documentation/api/authentication) — bearer token format


---

<!-- doc:embed/builder-api -->
# Builder & renders API

Server-side endpoints for the embedded template builder. Create templates in a partner project, list them back, and read the render history that embed sessions produce. These power the host application around the iframe — the iframe itself uses the session JWT, while server-to-server calls use a project API key.

```http
POST /v1/embed/builder/templates
GET  /v1/embed/builder/templates
GET  /v1/embed/renders
```

## Authentication at a glance

| Endpoint | Accepts |
|---|---|
| `POST /v1/embed/builder/templates` | Partner API key **or** embed session JWT |
| `GET /v1/embed/builder/templates` | Partner API key **only** |
| `GET /v1/embed/renders` | Partner API key **only** |

The "partner API key" is an ordinary project API key for a project that has the embed partner enabled. Send it as `Authorization: Bearer $CRAFTKIT_API_KEY`. The "session JWT" is the short-lived token minted for an iframe — send it as `Authorization: Bearer <session_token>`. Session JWTs are validated against the iframe request origin, so they only work from inside an allow-listed embed.

---

## POST /v1/embed/builder/templates

Creates a new template in the authenticated partner's project from a builder draft. The draft is stored verbatim; if the caller has publish rights and the `layout` parses as a render-ready document, a first version is published automatically.

### Auth

Accepts **either** credential:

- `Authorization: Bearer $CRAFTKIT_API_KEY` — partner API key. Server-to-server; grants both `saveDraft` and `publish`.
- `Authorization: Bearer <session_token>` — embed session JWT. Rights come from the session's `permissions` claims (`saveDraft`, `publish`). The request must originate from an allow-listed iframe origin.

A caller without `saveDraft` rights is rejected with `403 permission_denied`. Publishing is attempted only when the caller also has `publish`.

### Quick Start

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/embed/builder/templates \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "documentTemplate": {
      "name": "Charter Handover",
      "presetKey": "charter-handover",
      "layout": { "sections": [] }
    }
  }'
```

**Node.js**
```typescript
const res = await fetch('https://api.craftkit.dev/v1/embed/builder/templates', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    documentTemplate: {
      name: 'Charter Handover',
      presetKey: 'charter-handover',
      layout: { sections: [] },
    },
  }),
});

const { documentTemplate } = await res.json();
// documentTemplate._id is the new template's UUID
```

**Python**
```python
import os, requests

res = requests.post(
    "https://api.craftkit.dev/v1/embed/builder/templates",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
    json={
        "documentTemplate": {
            "name": "Charter Handover",
            "presetKey": "charter-handover",
            "layout": {"sections": []},
        }
    },
)
result = res.json()
```

### Request body

```json
{
  "documentTemplate": {
    "name": "Charter Handover",
    "presetKey": "charter-handover",
    "layout": { "sections": [] }
  }
}
```

| Field | Type | Description | Default |
|---|---|---|---|
| `documentTemplate` | object | The builder draft to persist. Stored as-is in `builderDraft`; the whole object is also returned (adapted) in the response. | `{}` |
| `documentTemplate.name` | string | Template display name. The slug is derived from it and suffixed with a short random token to keep it unique. | `"Untitled template"` |
| `documentTemplate.layout` | object | The builder's internal canvas draft (`react-email-dnd` `CanvasDocument`). It is **not** validated on create — publishing parses it as a render-ready layout and silently skips publish if it does not match. | — |
| `documentTemplate.presetKey` | string \| null | Template-type identifier (for example `charter-handover`). Persisted to `presetKey`. | `null` |

> The draft is opaque to this endpoint. Any extra keys the builder sends (`category`, `fields`, `sections`, `bindings`, `design`, `settings`, …) are stored inside `builderDraft` and round-tripped back through the response adapter. Only `name`, `layout`, and `presetKey` are interpreted server-side.

### Response — `200 OK`

```json
{
  "documentTemplate": {
    "_id": "0193c2c3-...",
    "organizationId": "proj_01j...",
    "name": "Charter Handover",
    "sD": false,
    "cAt": "2026-06-05T10:00:00.000Z",
    "presetKey": "charter-handover",
    "isCustomTemplate": true,
    "layout": { "sections": [] },
    "design": null
  }
}
```

| Field | Type | Description |
|---|---|---|
| `documentTemplate._id` | string | New template UUID. Use it as the `templateExternalId` when minting a fill session. |
| `documentTemplate.organizationId` | string | The owning project's id (Craftkit's tenant boundary). |
| `documentTemplate.name` | string | Resolved template name. |
| `documentTemplate.sD` | boolean | Soft-delete flag. `false` for a freshly created template. |
| `documentTemplate.cAt` | string | ISO-8601 creation timestamp. |
| `documentTemplate.presetKey` | string \| null | The preset key you supplied. |
| `documentTemplate.layout` | object \| null | The persisted builder layout draft. |

The full adapted draft (`fields`, `sections`, `bindings`, `design`, `settings`, `category`, …) is included when present in the draft.

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `invalid_json` | Body wasn't valid JSON | Check `Content-Type` and JSON.stringify |
| 401 | `missing_or_invalid_authorization` | No bearer token, or neither an API key nor a valid session token | Send a project API key or a valid session JWT from an allow-listed origin |
| 403 | `permission_denied` | Authenticated, but the session lacks `saveDraft` rights | Mint the session with `permissions.saveDraft = true` |
| 500 | `insert_failed` | The template row could not be written | Retry; if it persists, contact support |

---

## GET /v1/embed/builder/templates

Lists all non-deleted templates in the authenticated partner's project, newest-updated first. Lightweight — it returns identifiers and the draft `category`, not the full draft.

### Auth

Partner API key **only**: `Authorization: Bearer $CRAFTKIT_API_KEY`. A session JWT is **not** accepted here.

### Quick Start

**curl**
```bash
curl https://api.craftkit.dev/v1/embed/builder/templates \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY"
```

**Node.js**
```typescript
const res = await fetch('https://api.craftkit.dev/v1/embed/builder/templates', {
  headers: { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` },
});
const { templates } = await res.json();
```

**Python**
```python
import os, requests

res = requests.get(
    "https://api.craftkit.dev/v1/embed/builder/templates",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
)
templates = res.json()["templates"]
```

### Response — `200 OK`

```json
{
  "templates": [
    {
      "id": "0193c2c3-...",
      "name": "Charter Handover",
      "slug": "charter-handover-1a2b3c4d",
      "createdAt": "2026-05-01T09:00:00.000Z",
      "updatedAt": "2026-06-01T12:00:00.000Z",
      "category": "charter"
    }
  ]
}
```

| Field | Type | Description |
|---|---|---|
| `templates` | array | Templates in the project, ordered by `updatedAt` descending. |
| `templates[].id` | string | Template UUID. |
| `templates[].name` | string | Template name. |
| `templates[].slug` | string | URL-safe slug (name-derived, random-suffixed). |
| `templates[].createdAt` | string | ISO-8601 creation timestamp. |
| `templates[].updatedAt` | string | ISO-8601 last-update timestamp. |
| `templates[].category` | string \| null | The draft's `category`, if set; otherwise `null`. |

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 401 | `missing_authorization` | No `Authorization` header | Send `Authorization: Bearer $CRAFTKIT_API_KEY` |
| 401 | `invalid_credentials` | API key not found, revoked, or embed not enabled for the project | Check the key and that the project has an embed partner |

---

## GET /v1/embed/renders

Lists renders (form submissions and other PDF instances) for the partner's project so an embedding host can show a document history without a Craftkit dashboard session. Internal `preview` and `dashboard` renders are always excluded.

### Auth

Partner API key **only**: `Authorization: Bearer $CRAFTKIT_API_KEY`. A session JWT is **not** accepted here.

### Quick Start

**curl**
```bash
curl "https://api.craftkit.dev/v1/embed/renders?status=succeeded&limit=50" \
  -H "Authorization: Bearer $CRAFTKIT_API_KEY"
```

**Node.js**
```typescript
const params = new URLSearchParams({ status: 'succeeded', limit: '50' });
const res = await fetch(`https://api.craftkit.dev/v1/embed/renders?${params}`, {
  headers: { Authorization: `Bearer ${process.env.CRAFTKIT_API_KEY}` },
});
const { renders, limit, offset } = await res.json();
```

**Python**
```python
import os, requests

res = requests.get(
    "https://api.craftkit.dev/v1/embed/renders",
    headers={"Authorization": f"Bearer {os.environ['CRAFTKIT_API_KEY']}"},
    params={"status": "succeeded", "limit": 50},
)
result = res.json()
```

### Query parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `limit` | integer | Page size. Clamped to `1`–`500`. | `100` |
| `offset` | integer | Number of rows to skip (clamped to `>= 0`). | `0` |
| `templateId` | string | Filter to a single Craftkit template UUID. | — |
| `status` | string | Filter by render status: `queued`, `rendering`, `succeeded`, `failed`, or `cancelled`. | — |

### Response — `200 OK`

```json
{
  "renders": [
    {
      "id": "0193c2c3-...",
      "status": "succeeded",
      "source": "form",
      "templateId": "0193c2c3-...",
      "templateName": "Charter Handover",
      "inputData": { "customer": { "name": "Acme Corp" } },
      "downloadUrl": "https://cdn.craftkit.dev/renders/0193c2c3-....pdf",
      "errorMessage": null,
      "durationMs": 1820,
      "createdAt": "2026-06-05T10:00:00.000Z",
      "completedAt": "2026-06-05T10:00:02.000Z"
    }
  ],
  "limit": 50,
  "offset": 0
}
```

| Field | Type | Description |
|---|---|---|
| `renders` | array | Matching renders, newest-created first. |
| `renders[].id` | string | Render UUID. |
| `renders[].status` | string | `queued`, `rendering`, `succeeded`, `failed`, or `cancelled`. |
| `renders[].source` | string | Origin of the render. `preview` and `dashboard` are never returned. |
| `renders[].templateId` | string | The render's template UUID. |
| `renders[].templateName` | string \| null | Template name, or `null` if the template was deleted. |
| `renders[].inputData` | object | The variable data the render was produced from. |
| `renders[].downloadUrl` | string \| null | Public PDF URL once the render succeeds; `null` until the asset exists. |
| `renders[].errorMessage` | string \| null | Failure detail, if the render failed. |
| `renders[].durationMs` | number \| null | Render time in milliseconds, when available. |
| `renders[].createdAt` | string | ISO-8601 enqueue timestamp. |
| `renders[].completedAt` | string \| null | ISO-8601 completion timestamp, or `null` if not finished. |
| `limit` | number | The effective (clamped) page size. |
| `offset` | number | The effective offset. |

> `status` is filtered in the handler after the page is fetched, so a page may contain fewer than `limit` rows when a status filter is applied. Paginate by `offset` until `renders` comes back empty.

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 401 | `missing_authorization` | No `Authorization` header | Send `Authorization: Bearer $CRAFTKIT_API_KEY` |
| 401 | `invalid_credentials` | API key not found, revoked, or embed not enabled for the project | Check the key and that the project has an embed partner |

## Related

- [Form submit API](/documentation/embed/form-submit-api) — the iframe-side submit + image upload endpoints that produce `form` renders
- [Embed catalogs](/documentation/api/embed-catalogs) — publish the variable catalog a builder session uses
- [Embed quickstart](/documentation/embed/quickstart) — mint a session and load the iframe
- [Authentication](/documentation/api/authentication) — bearer token format

---
_Last revised: 2026-06-26_


---

<!-- doc:embed/form-submit-api -->
# Form submit API

Endpoints for the form-fill embed. Submit a filled-in form to enqueue a render, and upload an image so a form field can carry a URL instead of a raw file. Both are called from inside the iframe and authenticate with the same session JWT used to load it.

```http
POST /v1/embed/form-submit/:sessionId
POST /v1/embed/form-submit/:sessionId/upload-image
```

## Authentication

Both endpoints take the **embed session JWT** — never a project API key:

```
Authorization: Bearer <session_token>
```

The token is verified against the iframe request origin (which must be allow-listed), and the `:sessionId` in the path must match the token's session. Sessions must be minted with `scope.mode = "fill"`; submit additionally requires the `submitForm` permission.

---

## POST /v1/embed/form-submit/:sessionId

Validates the submitted `data` against the template version's variable manifest, merges any JWT-claimed prefill **under** the user-supplied data (user data wins), and enqueues a render with `source = "form"` so the existing render-worker picks it up unchanged. Returns `202 Accepted` with a poll URL.

> **Collect-only mode.** When the session was minted with [`form.captureMode: "collect"`](/documentation/embed/sessions-api), this endpoint does **not** create a render or store the field data. It validates the data and emits a signed [`form.submitted`](/documentation/api/webhooks) webhook to the project's subscribers, then returns `202 { "submitted": true, "sessionId": "…" }` (no `id`/`pollUrl` — there is no render to poll). The delivery is awaited into the queue, so `202` means "durably enqueued". If no active webhook is subscribed to `form.submitted`, the submit fails with `409 no_webhook_subscriber` rather than silently dropping the data.

### Auth

- `Authorization: Bearer <session_token>` — a **fill**-mode session JWT with `permissions.submitForm = true`.
- The request origin must be allow-listed (used as the token audience).
- `:sessionId` must equal the token's session id.

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `sessionId` | string | The embed session id. Must match the bearer token's session. | — |

### Quick Start

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/embed/form-submit/$SESSION_ID \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "customer.name": "Acme Corp",
      "customer.email": "hello@acme.com"
    }
  }'
```

**Node.js**
```typescript
const res = await fetch(`https://api.craftkit.dev/v1/embed/form-submit/${sessionId}`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${sessionToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    data: {
      'customer.name': 'Acme Corp',
      'customer.email': 'hello@acme.com',
    },
  }),
});

const { id, status, pollUrl } = await res.json();
```

**Python**
```python
import requests

res = requests.post(
    f"https://api.craftkit.dev/v1/embed/form-submit/{session_id}",
    headers={"Authorization": f"Bearer {session_token}"},
    json={
        "data": {
            "customer.name": "Acme Corp",
            "customer.email": "hello@acme.com",
        }
    },
)
job = res.json()
```

### Request body

```json
{
  "data": {
    "customer.name": "Acme Corp",
    "customer.email": "hello@acme.com"
  },
  "datasetSelection": {
    "booking": "bk_123"
  }
}
```

| Field | Type | Description | Default |
|---|---|---|---|
| `data` | object | Variable values to render. Required. May use flat dot-keys (`"customer.name"`) — they are expanded to nested objects (`{ customer: { name } }`) before manifest validation, so they match the renderer's lookups. Validated against the template version's manifest. | — |
| `datasetSelection` | object | Optional string→string map persisted on the render row (for example, which source records the form was filled from). | `null` |

> **Prefill merge.** Any `form.prefill` carried in the session JWT is applied **under** your `data` — your values always win. Prefill keys not present in the template manifest are dropped silently.

> **Dotted keys.** The form embed emits flat dot-keyed state. Reserved property names (`__proto__`, `constructor`, `prototype`) anywhere in a key are stripped during expansion to prevent prototype pollution.

### Response — `202 Accepted`

```json
{
  "id": "0193c2c3-...",
  "status": "queued",
  "pollUrl": "https://api.craftkit.dev/v1/embed/renders/0193c2c3-...",
  "downloadUrl": null,
  "errorMessage": null,
  "createdAt": "2026-06-05T10:00:00.000Z"
}
```

| Field | Type | Description |
|---|---|---|
| `id` | string | Render id (UUID). |
| `status` | string | `queued` initially. Progresses to `rendering` then `succeeded` \| `failed`. |
| `pollUrl` | string | Embed-scoped poll URL (`/v1/embed/renders/:id`). Poll it with the **session JWT** — not the partner API key. |
| `downloadUrl` | string \| null | `null` until the render succeeds. |
| `errorMessage` | string \| null | Populated on failure. |
| `createdAt` | string | ISO-8601 timestamp. |

### Response (collect-only mode) — `202 Accepted`

When the session's `form.captureMode` is `collect`, there is no render. The `form.submitted` webhook carries the data instead:

```json
{
  "submitted": true,
  "sessionId": "0193c2c3-..."
}
```

| Field | Type | Description |
|---|---|---|
| `submitted` | boolean | Always `true`. Means the `form.submitted` delivery was durably enqueued. |
| `sessionId` | string | The embed session id the submission belongs to. |

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `bad_request` | Request origin is not in the allowed origins list | Add the embed origin to the partner's allow-list |
| 400 | `invalid_json` | Body wasn't valid JSON | Check `Content-Type` and JSON.stringify |
| 400 | `invalid_request` | Body didn't match `{ data, datasetSelection? }` | See the request body table; inspect `issues` |
| 400 | `invalid_input_data` | `data` didn't match the template manifest | Inspect `issues.fieldErrors` for offending keys |
| 401 | `unauthorized` | Missing/malformed `Authorization` header, or the session token failed verification | Send `Authorization: Bearer <session_token>` from an allow-listed origin |
| 403 | `wrong_mode` | Session is not `scope.mode = "fill"` | Mint a fill-mode session |
| 403 | `permission_denied` | Session lacks the `submitForm` permission | Mint the session with `permissions.submitForm = true` |
| 404 | `session_not_found` | `:sessionId` doesn't match the bearer token | Use the session id the token was minted for |
| 404 | `template_not_resolved` | Session scope carries no template id, or the external id isn't a UUID | Mint with `scope.templateExternalId` set to the Craftkit template UUID |
| 404 | `template_not_found` | No such template in this project | Check the template id and the session's project |
| 404 | `unpublished_template` | The template has no published version | Publish a version first |
| 409 | `no_webhook_subscriber` | (collect mode) No active webhook is subscribed to `form.submitted` | Subscribe a webhook to `form.submitted` in the dashboard |
| 503 | `queue_unavailable` | Render queue (or, in collect mode, the delivery queue) temporarily unreachable | Retry in a moment |
| 500 | `internal` | The render row could not be written/enqueued (or the delivery could not be enqueued) | Retry; if it persists, contact support |

---

## POST /v1/embed/form-submit/:sessionId/upload-image

Accepts a `multipart/form-data` upload with a single `file` field (an image) and stores it in object storage under the session's namespace. Returns the public URL so the form submit payload can carry a URL instead of a raw file. Called by the form embed before it POSTs the JSON form data.

### Auth

- `Authorization: Bearer <session_token>` — a **fill**-mode session JWT.
- The request origin must be allow-listed.
- `:sessionId` must equal the token's session id.

The `submitForm` permission is **not** required for upload — only fill mode.

### Path parameters

| Field | Type | Description | Default |
|---|---|---|---|
| `sessionId` | string | The embed session id. Must match the bearer token's session. | — |

### Request body

`multipart/form-data` with one field:

| Field | Type | Description | Default |
|---|---|---|---|
| `file` | file | The image to upload. MIME type must start with `image/`. Max size 10 MB. | — |

The stored object key is `embed-uploads/{projectId}/{sessionId}/{uuid}.{ext}`. The extension comes from the original filename, falling back to a MIME-type mapping (jpg, png, gif, webp, svg) or `.bin`.

### Quick Start

**curl**
```bash
curl -X POST https://api.craftkit.dev/v1/embed/form-submit/$SESSION_ID/upload-image \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -F "file=@logo.png"
```

**Node.js**
```typescript
const form = new FormData();
form.append('file', fileBlob, 'logo.png');

const res = await fetch(
  `https://api.craftkit.dev/v1/embed/form-submit/${sessionId}/upload-image`,
  {
    method: 'POST',
    headers: { Authorization: `Bearer ${sessionToken}` },
    body: form,
  },
);

const { url } = await res.json();
```

**Python**
```python
import requests

with open("logo.png", "rb") as f:
    res = requests.post(
        f"https://api.craftkit.dev/v1/embed/form-submit/{session_id}/upload-image",
        headers={"Authorization": f"Bearer {session_token}"},
        files={"file": ("logo.png", f, "image/png")},
    )

url = res.json()["url"]
```

### Response — `200 OK`

```json
{
  "url": "https://cdn.craftkit.dev/embed-uploads/proj_01j.../sess_01j.../9f1c....png"
}
```

| Field | Type | Description |
|---|---|---|
| `url` | string | Public URL of the uploaded image. Pass it back as the value of the matching image variable in the `data` of your form submit. |

### Errors

| HTTP | Code | Meaning | Fix |
|---|---|---|---|
| 400 | `bad_request` | Request origin is not in the allowed origins list | Add the embed origin to the partner's allow-list |
| 400 | `invalid_multipart` | Body wasn't valid `multipart/form-data` | Send the file as multipart, not JSON |
| 400 | `missing_file` | No `file` field in the form data | Include a `file` part |
| 401 | `unauthorized` | Missing/malformed `Authorization` header, or the session token failed verification | Send `Authorization: Bearer <session_token>` from an allow-listed origin |
| 403 | `wrong_mode` | Session is not `scope.mode = "fill"` | Mint a fill-mode session |
| 404 | `session_not_found` | `:sessionId` doesn't match the bearer token | Use the session id the token was minted for |
| 413 | `file_too_large` | File exceeds the 10 MB limit | Compress or resize the image |
| 415 | `invalid_type` | The file's MIME type is not `image/*` | Upload an image file |

## Related

- [Builder & renders API](/documentation/embed/builder-api) — create templates and read the `form` renders this endpoint produces
- [Embed catalogs](/documentation/api/embed-catalogs) — the variable catalog that becomes the form's fields
- [Embed quickstart](/documentation/embed/quickstart) — mint a fill session and load the iframe
- [Render a template](/documentation/api/render-template) — the partner-API-key equivalent of submitting data

---
_Last revised: 2026-06-26_


---

<!-- doc:architecture/overview -->
# 01 — System Architecture Overview

## At a glance

Craftkit is a **TypeScript monorepo** of two deployable apps and several
shared packages, coordinated by Turborepo. The apps deploy independently
to suit their runtime profiles.

```
┌──────────────────────────────────────────────────────────────────┐
│                          Marketing site                          │
│                          (apps/web · /)                          │
└──────────────────────────────────────────────────────────────────┘
                                │
┌──────────────────────────────────────────────────────────────────┐
│                          Dashboard (auth)                        │
│                  (apps/web · /dashboard/...)                     │
│   · Sign-up / login (better-auth)                                │
│   · Projects, templates, renders, API keys, webhooks             │
│   · Embed: keys, origins, catalogs, sessions, sandbox            │
└──────────────────────────────────────────────────────────────────┘
                                │
┌──────────────────────────────────────────────────────────────────┐
│                         Public REST API                          │
│                   (apps/web · /v1/...)                           │
│   · POST /v1/templates/:slug/render   (API-key auth)             │
│   · GET  /v1/renders/:id              (poll)                     │
│   · POST /v1/hooks/:token             (inbound, per-template)    │
│   · POST /v1/embed/sessions           (mint embed JWT)           │
│   · POST /v1/embed/sessions/refresh   (renew)                    │
└──────────────────────────────────────────────────────────────────┘
                                │
                       enqueue render jobs
                                ▼
┌──────────────────────────────────────────────────────────────────┐
│                          Render worker                           │
│                       (apps/render-worker)                       │
│   · BullMQ consumer                                              │
│   · Handlebars compile · Puppeteer print · S3 upload            │
│   · Outgoing webhooks with HMAC                                  │
└──────────────────────────────────────────────────────────────────┘
                                │
┌──────────────────────────────────────────────────────────────────┐
│                         Embed iframe page                        │
│                 (apps/web · /embed/builder)                      │
│   · No-chrome layout                                             │
│   · JWT-validated                                                │
│   · Catalog-aware variable picker                                │
│   · postMessage protocol with parent window                     │
└──────────────────────────────────────────────────────────────────┘
```

## Apps

### `apps/web`

A Next.js 15 (App Router) application that owns:
- The marketing site (`/`)
- The authenticated dashboard (`/dashboard/...`)
- The embed iframe page (`/embed/builder`)
- The public REST API (`/v1/...`)
- The internal tRPC layer (used by dashboard server actions)

Deploys to Vercel or any Node runtime. Stateless except for the database.

### `apps/render-worker`

A standalone Node process that consumes BullMQ jobs and renders PDFs via
Puppeteer. Kept separate from the web app because:

1. Puppeteer's memory profile (~300 MB resident per worker) doesn't fit a
   serverless function.
2. Render concurrency tuning is independent from request concurrency.
3. Worker can be scaled horizontally or replaced (e.g., with `@react-pdf/renderer`)
   without touching the API.

Deploys to Railway, Fly.io, or any Docker-friendly host.

## Packages

| Package | Purpose |
|---|---|
| `@craftkit/db` | Drizzle schema, migrations, typed client. Exports both `node-postgres` (worker) and `postgres-js` (web) flavors via the same schema. |
| `@craftkit/render-core` | Pure (framework-agnostic) renderer: Tiptap doc → variable manifest → Zod/JSON Schema → Handlebars-compiled HTML. Used by both web (server actions on save) and worker (render time). |
| `@craftkit/schema` | Shared Zod schemas: variable manifest, render request, webhook envelope. Single source of truth for API contracts. |
| `@craftkit/sdk` | Public TypeScript client (`@craftkit/sdk`) — what customers `npm install`. Zero deps. |
| `@craftkit/embed` | Public embed SDK — drop-in `<script>` partners use to mount the iframe. |
| `@craftkit/ui` | Shared shadcn primitives + brand tokens + `cn()` helper. |
| `@craftkit/tsconfig` | TS preset configs: `next.json`, `node.json`, `react-library.json`. |

## External infrastructure

| Component | Dev | Production |
|---|---|---|
| Database | Postgres 16 (Docker) | Neon / Supabase / managed PG |
| Queue | Redis 7 (Docker) | Upstash Redis |
| Object storage | MinIO (Docker, S3-compat) | Cloudflare R2 |
| PDF engine | Puppeteer + bundled Chromium | Puppeteer + `@sparticuz/chromium` |
| Email (v0.2+) | Resend / Postmark via partner BYO keys | same |
| AI (v0.2+) | OpenAI / Anthropic / Together via provider abstraction | same |

## Request flow — synchronous render API

```
1. Customer POSTs /v1/templates/:slug/render (Bearer ck_live_…)
2. Auth middleware resolves API key → project_id
3. Load template (slug + project) and its current_version
4. Validate request body against version's auto-generated Zod schema
5. Insert renders row (status=queued)
6. Enqueue BullMQ job with render_id
7. Respond 202 { id, status, poll_url }
```

## Request flow — async worker

```
1. Worker pulls job from queue
2. Load render → template_version → input data
3. Compile: render-core renders Handlebars(compiledHtml, data) → final HTML
4. Print: Puppeteer launches a page, sets HTML, prints PDF
5. Upload PDF to S3/R2, generate signed URL
6. Update renders row (status=succeeded, asset_url)
7. Enqueue webhook deliveries (separate queue)
8. Webhook deliverer POSTs to each registered webhook with HMAC signature
```

## Boundaries

- **No shared mutable state.** Everything is in Postgres + Redis + S3.
  Workers can be killed and restarted at any time.
- **No partner data crosses boundaries.** Embed iframe has no read access
  to partner DB; partner backend never reads Craftkit's internal state
  except via documented API.
- **No tenant data leakage between projects.** Every query is scoped by
  `project_id` at the data-access layer.

## Configuration & secrets

All runtime config flows through environment variables documented in
`engineering/02-environment-variables.md`. Secrets are never stored in
code. JWT signing keys are stored in DB (rotated via admin tools), API
keys are hashed at rest with the prefix retained for identification.

## Anti-residue policy

Craftkit was inspired by patterns from a separate codebase, but **zero**
identifiers, vocabulary, schemas, or values from that codebase appear here.
See `architecture/07-anti-residue.md`. The check is enforced by
`scripts/anti-residue-check.sh` in CI.

---
_Last revised: 2026-05-02_


---

<!-- doc:architecture/data-model -->
# 04 — Data Model

This is the relational data model for Craftkit v0.1 + v0.2 (with embed). Tables
are defined in `packages/db/src/schema.ts` using Drizzle.

## Identity & access

```
users
  id                uuid pk
  email             citext unique
  email_verified_at timestamp
  name              text
  avatar_url        text
  created_at        timestamp default now
  updated_at        timestamp default now

sessions             (managed by better-auth)
  id                text pk
  user_id           uuid fk users
  expires_at        timestamp
  ...
```

## Project ownership

A `project` is the unit of isolation. A user owns N projects. All API keys,
templates, renders, and webhooks are scoped to a project.

```
projects
  id                uuid pk
  user_id           uuid fk users        # single-user in v0.1
  name              text
  slug              text                  # url slug per user
  brand_color       text                  # for embed branding
  brand_logo_url    text
  created_at        timestamp
  updated_at        timestamp
  deleted_at        timestamp nullable
  unique (user_id, slug)
```

## Templates & versions

Templates are immutable once published — every publish creates a new
`template_version` row. The mutable working draft lives on `templates`.

```
templates
  id                uuid pk
  project_id        uuid fk projects
  name              text
  slug              text                  # unique per project
  current_version_id uuid fk template_versions nullable
  draft_content_json jsonb                # latest unpublished Tiptap doc
  draft_updated_at   timestamp
  created_at        timestamp
  deleted_at        timestamp nullable
  unique (project_id, slug)

template_versions
  id                uuid pk
  template_id       uuid fk templates
  version_number    integer
  content_json      jsonb                  # Tiptap doc (immutable snapshot)
  compiled_html     text                   # Handlebars template
  variables_manifest jsonb                  # extracted manifest (Variable[])
  json_schema       jsonb                   # auto-generated JSON Schema
  page_settings     jsonb                   # paper size, margin, orientation
  published_at      timestamp
  unique (template_id, version_number)
```

## API keys

API keys authenticate the public REST API. Stored hashed, prefix retained
for identification.

```
api_keys
  id                uuid pk
  project_id        uuid fk projects
  name              text                   # human label
  prefix            text                   # first 8 chars after ck_live_
  key_hash          text                   # bcrypt or argon2
  last_used_at      timestamp
  created_at        timestamp
  revoked_at        timestamp nullable
```

Format: `ck_live_<24-char-base32>`. The full key is shown once on creation.

## Webhooks

```
webhooks
  id                uuid pk
  project_id        uuid fk projects
  url               text
  secret            text                    # used for HMAC signature
  events            text[]                  # subscribed event types
  active            boolean
  created_at        timestamp

webhook_deliveries
  id                uuid pk
  webhook_id        uuid fk webhooks
  render_id         uuid fk renders nullable
  embed_session_id  uuid fk embed_sessions nullable
  event_type        text
  payload           jsonb
  attempts          integer
  status            enum (pending|delivered|failed|abandoned)
  response_status   integer nullable
  response_body     text nullable
  next_retry_at     timestamp nullable
  delivered_at      timestamp nullable
```

## Renders

```
renders
  id                uuid pk
  project_id        uuid fk projects
  template_version_id uuid fk template_versions
  api_key_id        uuid fk api_keys nullable    # null for embed-triggered
  embed_session_id  uuid fk embed_sessions nullable  # if from embed
  tenant_id         uuid fk tenants nullable     # for tenant attribution
  input_data        jsonb
  status            enum (queued|rendering|succeeded|failed)
  asset_url         text nullable
  asset_size_bytes  integer nullable
  error             text nullable
  created_at        timestamp
  started_at        timestamp nullable
  completed_at      timestamp nullable
```

## Tenancy & embed (v0.2)

This is the new layer added in v0.2 to support partner SaaS embedding
Craftkit. See `architecture/06-tenancy-model.md` for the conceptual model.

```
embed_partners                              # one project becomes a "partner"
  id                uuid pk
  project_id        uuid fk projects unique
  display_name      text
  status            enum (active|suspended)
  created_at        timestamp
  updated_at        timestamp

embed_partner_keys
  id                uuid pk
  partner_id        uuid fk embed_partners
  kind              enum (publishable|secret|signing_public|signing_private)
  prefix            text
  hash              text nullable           # for secret keys; null for public
  public_key_pem    text nullable           # for signing keys
  kid               text nullable           # JWT key id for signing keys
  status            enum (active|revoked|rotating)
  created_at        timestamp
  revoked_at        timestamp nullable

embed_partner_origins
  id                uuid pk
  partner_id        uuid fk embed_partners
  origin            text                    # may include wildcards (*.example.com)
  environment       enum (production|staging|development)
  created_at        timestamp

embed_partner_permission_presets
  id                uuid pk
  partner_id        uuid fk embed_partners
  name              text                    # 'admin', 'editor', 'viewer', or custom
  permissions       jsonb                   # { publish: bool, save_draft: bool, ... }
  created_at        timestamp

tenants                                     # partner's customers (e.g. an org)
  id                uuid pk
  partner_id        uuid fk embed_partners
  external_id       text                    # partner-supplied
  display_name      text
  brand_color       text nullable
  brand_logo_url    text nullable
  status            enum (active|paused|disabled)
  created_at        timestamp
  unique (partner_id, external_id)

actors                                      # end-users from partner's customers
  id                uuid pk
  partner_id        uuid fk embed_partners
  tenant_id         uuid fk tenants
  external_id       text                    # partner-supplied user id
  display_name      text
  email             citext nullable
  preferences       jsonb                   # ui prefs, hint dismissal state
  first_seen_at     timestamp
  last_seen_at      timestamp
  unique (partner_id, tenant_id, external_id)

variable_catalogs                           # named, versioned per-partner
  id                uuid pk
  partner_id        uuid fk embed_partners
  name              text                    # 'kleesto-org-default'
  version           integer
  content           jsonb                   # CatalogSpec
  status            enum (draft|current|archived)
  created_at        timestamp
  unique (partner_id, name, version)

template_external_links                     # maps a craftkit template to a partner's external id
  template_id       uuid fk templates
  partner_id        uuid fk embed_partners
  tenant_id         uuid fk tenants
  external_id       text                    # partner's id for this template
  primary key (template_id, partner_id)
  unique (partner_id, tenant_id, external_id)

embed_sessions
  id                uuid pk                 # ck_sess_…
  partner_id        uuid fk embed_partners
  tenant_id         uuid fk tenants
  actor_id          uuid fk actors
  template_id       uuid fk templates nullable    # null when mode=create
  catalog_id        uuid fk variable_catalogs nullable
  mode              enum (edit|create|view)
  permissions       jsonb                   # snapshot of preset (or inline)
  branding          jsonb                   # snapshot of branding tokens
  callbacks         jsonb                   # on_published, on_close_url
  limits            jsonb                   # per-session abuse guards
  renew_token_hash  text                    # rotates each refresh
  jti_seen          text[]                  # replay protection (in-memory cache also)
  status            enum (active|expired|revoked|terminal)
  created_at        timestamp
  last_token_issued_at timestamp
  expires_at        timestamp               # of the latest token
  ended_at          timestamp nullable
  end_reason        text nullable

embed_session_events                        # full audit trail for the session inspector
  id                uuid pk
  session_id        uuid fk embed_sessions
  type              text                    # 'token.issued', 'iframe.loaded', 'variable.inserted', ...
  payload           jsonb
  occurred_at       timestamp
```

## Telemetry & billing (v0.2)

Append-only ledger of billable events. See `embed/09-billing-telemetry.md`.

```
usage_events
  id                uuid pk
  partner_id        uuid fk embed_partners nullable
  tenant_id         uuid fk tenants nullable
  project_id        uuid fk projects nullable
  unit              enum (render|embed_session|active_editor|ai_generation)
  quantity          integer default 1
  metadata          jsonb
  occurred_at       timestamp

usage_adjustments                           # corrections / refunds / credits
  id                uuid pk
  partner_id        uuid fk embed_partners
  unit              enum
  delta             integer
  reason            text
  created_by        uuid fk users
  created_at        timestamp
```

## AI generations (v0.2)

```
ai_generations
  id                uuid pk
  project_id        uuid fk projects
  embed_session_id  uuid fk embed_sessions nullable
  kind              enum (template|edit|workflow)
  prompt            text
  model             text
  input_tokens      integer
  output_tokens     integer
  output            jsonb
  accepted          boolean nullable
  created_at        timestamp
```

## Indices we always want

- `(project_id, created_at desc)` on `renders` — dashboard list view
- `(template_id, version_number desc)` on `template_versions` — history
- `(partner_id, status, expires_at)` on `embed_sessions` — live console
- `(unit, occurred_at)` on `usage_events` — billing rollups
- Unique partial: `(project_id, slug) where deleted_at is null` on templates

## Soft deletes

Templates and projects use soft delete (`deleted_at`). Renders, sessions,
events, usage are append-only and never deleted (only archived after the
retention window — TBD per plan tier).

---
_Last revised: 2026-05-02_


---

<!-- doc:architecture/render-pipeline -->
# 05 — Render Pipeline

## End-to-end trace

```
Customer POSTs /v1/templates/:slug/render
  ↓
[ web ] auth: resolve API key → project_id
  ↓
[ web ] load template + current_version (must be published)
  ↓
[ web ] validate input against version's auto-generated Zod schema
        → 422 with issues array on failure
  ↓
[ web ] insert renders row (status=queued)
  ↓
[ web ] enqueue BullMQ job { renderId } on `renders` queue
  ↓
[ web ] respond 202 { id, status, poll_url, estimated_seconds }
  ↓
─── async boundary ─────────────────────────────────────────────────
  ↓
[ worker ] pick up job from `renders` queue
  ↓
[ worker ] update render: status=rendering, started_at=now
  ↓
[ worker ] load template_version (compiled_html, page_settings)
  ↓
[ worker ] render-core: Handlebars.compile(compiled_html)(input_data) → final HTML
  ↓
[ worker ] wrap with @page CSS for paper size + margin
  ↓
[ worker ] Puppeteer: page.setContent(html), page.pdf({ format: …})
  ↓
[ worker ] upload PDF to S3/R2 at  renders/{project_id}/{render_id}.pdf
  ↓
[ worker ] generate signed URL (24h expiry)
  ↓
[ worker ] update render: status=succeeded, asset_url, completed_at
  ↓
[ worker ] for each project webhook subscribed to render.succeeded:
            enqueue webhook delivery job
  ↓
[ deliverer ] POST { event, data } with HMAC signature header
              → retry on 5xx with exponential backoff
              → mark delivery row delivered/failed
```

## Queues

| Queue | Concurrency | Notes |
|---|---|---|
| `renders` | 4 (per worker) | Throttle to GPU/CPU budget; back-pressure via Redis depth |
| `webhook_deliveries` | 16 | I/O bound; high concurrency safe |
| `embed_audit` | 8 | Append-only event ingestion |

Each queue uses BullMQ's standard retry strategy:
- 5 attempts max
- Exponential backoff starting at 5s, capped at 5m
- Failed jobs land in a dead-letter queue inspectable from the dashboard

## Why a separate worker process

1. Puppeteer's resident memory is ~300 MB even when idle. Stuffing it into a
   serverless function blows cold-start budgets and per-request memory caps.
2. Render concurrency is pinned by CPU (Chromium is CPU-heavy). Web request
   concurrency is pinned by I/O. Conflating them produces bad provisioning.
3. The worker can be replaced with `@react-pdf/renderer`, `Carbone`, or
   another engine without touching the API surface.

## Browser pool

The worker maintains a small pool (default 2) of Chromium instances:
- Each instance handles many sequential renders (page.close() between)
- Browser is recycled after N renders (default 50) to prevent memory leaks
- On any thrown error, the offending browser is destroyed and replaced

## HTML wrapping

`render-core` produces Handlebars-templated HTML (the body). The worker
wraps it with the page-settings-derived CSS:

```css
@page {
  size: A4 portrait;
  margin: 20mm;
}
body { font-family: Inter, sans-serif; line-height: 1.5; color: #0F0E0C; }
@page :first { margin-top: 30mm; }
```

The wrapper template can include partner-supplied custom fonts (loaded via
`@font-face`), watermarks, and headers/footers.

## Caching

- **Compiled Handlebars templates** are cached in worker memory keyed by
  `template_version_id` (LRU, max 1000 entries).
- **Generated PDFs** are NOT cached — each request gets a fresh render.
  Customers can implement caching client-side using their own request
  hashes if they want.

## Error handling

| Failure | Behavior |
|---|---|
| Handlebars compile error | Render fails immediately, error stored, no retries |
| Puppeteer crash | Browser destroyed; job retries (up to 5) with fresh browser |
| S3 upload failure | Job retries with backoff |
| OOM in worker | Process exits, orchestrator restarts; job re-enqueued |
| Timeout (default 60s/render) | Job marked failed, no retry |

All failures fire `render.failed` webhook with structured error context so
customers can react automatically.

## Inbound webhooks

Per-template inbound webhook URLs (`POST /v1/hooks/:token`) follow the same
pipeline but skip the API-key auth step in favor of token authentication +
optional HMAC verification. The body is treated as the variable data.

```
POST /v1/hooks/abc123def456
Content-Type: application/json
X-Craftkit-Signature: sha256=…  (optional, if HMAC enabled)

{ "customer": { "name": "..." }, ... }
```

Use case: drop the URL into Stripe / Zapier / Make / n8n as a webhook
destination, and Craftkit will render the template when an event fires.

## Embed-triggered renders (v0.2)

When a render is initiated from inside an embed session (e.g., the user
clicks "Generate document" in the partner's UI):

1. Partner's backend mints a render with the embed session token
2. The render row carries `embed_session_id` and `tenant_id` for attribution
3. Usage event records `tenant_id` so it counts against per-tenant limits

## Performance targets

| Metric | Target |
|---|---|
| API response time (sync, queue insert) | p99 < 100 ms |
| Render time (simple invoice, no images) | p50 < 800 ms |
| Render time (complex with 5 images) | p95 < 3 s |
| Worker throughput (1 vCPU, 2 GB) | 60 renders/min |
| Webhook delivery time | p95 < 2 s after render |

These are validated by `scripts/smoke.mjs` and ongoing load tests.

---
_Last revised: 2026-05-02_


---

<!-- doc:architecture/tenancy -->
# 06 — Tenancy Model

The tenancy model is the structural innovation that lets Craftkit be **both**
a standalone SaaS for customers AND an embeddable platform for SaaS partners,
without code branching or schema duplication.

## The four-level hierarchy

```
   ┌─────────────────────────────────────────────────────────────┐
   │  USER  (Craftkit account)                                   │
   │   · Signed up at app.craftkit.dev                           │
   │   · Pays Craftkit                                           │
   │   · Owns N projects                                         │
   └─────────────────────────────────────────────────────────────┘
                                │
                                ▼
   ┌─────────────────────────────────────────────────────────────┐
   │  PROJECT  (workspace / app)                                 │
   │   · Owns templates, API keys, webhooks, renders             │
   │   · MAY be marked as "embed partner" → unlocks embed mode   │
   │     ─────────────────────────────────────────────────────   │
   │     when in embed-partner mode, also owns:                  │
   │     · publishable + secret + signing keys                   │
   │     · allowed origins                                       │
   │     · permission presets                                    │
   │     · variable catalogs                                     │
   │     · tenants and actors (see below)                        │
   └─────────────────────────────────────────────────────────────┘
                                │
        ─── if project is embed-partner mode ───
                                ▼
   ┌─────────────────────────────────────────────────────────────┐
   │  TENANT  (partner's customer organization)                  │
   │   · One tenant = one of the partner's end customers         │
   │   · Identified by partner-supplied external_id              │
   │   · Has its own templates (siloed from sibling tenants)     │
   │   · Drives per-tenant billing & limits                      │
   └─────────────────────────────────────────────────────────────┘
                                │
                                ▼
   ┌─────────────────────────────────────────────────────────────┐
   │  ACTOR  (end-user inside a tenant)                          │
   │   · The human who opens the embed builder                   │
   │   · Identified by partner-supplied external_id              │
   │   · Has UI preferences, hint dismissal state                │
   │   · Generates audit-trail attribution                       │
   └─────────────────────────────────────────────────────────────┘
```

## Two modes, one schema

The same database serves two operating modes:

### Mode 1 — Direct SaaS

A user signs up at `app.craftkit.dev`, creates a project, builds templates,
calls `/v1/render` from their backend. **No tenants. No actors. No embed.**

This is v0.1 — works today.

### Mode 2 — Embed partner

A user signs up, creates a project, then **enables embed mode** on it. The
project gains a `embed_partners` row and unlocks:

- Publishable + secret + signing key trio
- Origin allowlist
- Permission presets
- Variable catalogs (versioned)
- The ability to mint `embed_sessions` for tenants/actors

When their tenants edit templates inside the iframe, those templates are
**owned by the project but attributed to a tenant** via
`template_external_links`. Renders triggered for that tenant carry the
`tenant_id` in the `renders` row for billing & isolation.

## Isolation guarantees

| Boundary | Enforced by |
|---|---|
| User can only see their own projects | `where user_id = $1` on every query |
| Project A can't see Project B's data | `where project_id = $1` on every query |
| Partner can't see another partner's tenants | `where partner_id = $1` |
| Tenant A can't see Tenant B's templates | `where tenant_id = $1` (in embed-attributed queries) |
| Iframe at session X can only access session X's catalog/template | JWT subject + claims, server-validated |

These are enforced **at the data-access layer** (in `lib/projects.ts`, `lib/embed-sessions.ts`), not as application-level filters in route handlers. This makes leakage almost impossible without a deliberate bypass.

## Vocabulary discipline

The vocabulary is generic-by-construction:

- **`partner`** — a Craftkit account holder running an embed integration. Never "tenant", never "customer."
- **`tenant`** — a partner's end customer (the org that pays them). Never "organization", never "team", never "workspace."
- **`actor`** — a human at the tenant who uses the embed builder. Never "user" (reserved for Craftkit account holders).

This discipline matters because some inspirational codebases use these words
interchangeably — leading to ambiguous permission checks. We never do.

## Why a project becomes a partner (rather than the user)

Considered alternatives:

1. **User → Partner direct** — rejected because users may run multiple
   product integrations (different brands, billing).
2. **Separate `partners` table not tied to project** — rejected because
   templates would have ambiguous ownership.
3. **Project IS the partner** — chosen. A project that has an
   `embed_partners` row attached is "in embed mode." Templates are owned
   by the project; tenants live underneath.

## Lifecycle: from partner signup to first session

```
1. Founder signs up at app.craftkit.dev → user row
2. Creates project "kleesto-platform" → projects row
3. Settings → Embed → Enable → embed_partners row + 3 keys generated
4. Adds origins (https://app.kleesto.com, http://localhost:3000)
5. Defines a variable catalog (named, v1)
6. Defines permission presets (admin, editor, viewer)
7. Sets webhook URL for embed events

──── partner integrates Craftkit into their product ────

8. End user (kleesto's customer) clicks "Edit template" in kleesto UI
9. kleesto backend POSTs /v1/embed/sessions with secret key:
   - tenant.external_id (their org id)
   - actor.external_id (their user id)
   - catalog_ref (or inline catalog)
   - template.external_id (their template id, optional)
   - permissions_preset (e.g. "editor")
10. Craftkit:
    - upserts tenant by (partner_id, external_id)
    - upserts actor by (partner_id, tenant_id, external_id)
    - finds template by template_external_links OR creates a draft if mode=create
    - mints JWT (5 min TTL) with claims
    - returns { session_token, iframe_url, expires_at, renew_token }
11. kleesto frontend mounts iframe at /embed/builder?session_token=…
12. iframe validates JWT, fetches catalog, hydrates Tiptap editor
13. user edits → publishes
14. webhook fires to kleesto's backend with template + version
```

## How partners evolve their data model

A catalog is **versioned**, never mutated in place. When a partner adds a
field, removes a field, or renames a key:

- They publish a new catalog version (v2, v3, …)
- Existing template chips reference the OLD version's keys
- On next session for that template, Craftkit:
  - For renamed keys: offers one-click remap (writes a `key_alias`)
  - For removed keys: shows missing-field UI; user can remap or remove
  - For added keys: silently available in the picker

Catalogs are diffable in the admin UI to surface the impact of every change.

## Self-hosted / on-prem

The same schema runs on-prem with no changes. The "Craftkit account holder"
becomes the deploying organization; everything else is identical. This
means partner code paths, embed JWTs, and observability all just work in
self-hosted deploys.

---
_Last revised: 2026-05-02_


---

<!-- doc:architecture/tech-stack -->
# 03 — Tech Stack

Locked recommendations for v0.1 / v0.2. Each choice has a documented reason
and a fallback — we don't change them on a whim.

## Runtime & language

| Layer | Choice | Rationale |
|---|---|---|
| Language | **TypeScript 5.x** (strict) | Shared types end-to-end |
| Runtime | **Node 22 LTS** | Native fetch, native test runner, ES modules |
| Package manager | **pnpm 9** | Faster than npm, content-addressable store, strict deps |
| Monorepo | **Turborepo** | Caching, pipeline graph, zero config |
| Lint + format | **Biome** | One tool, faster than ESLint + Prettier |

## Frontend

| Layer | Choice | Rationale |
|---|---|---|
| Framework | **Next.js 15** (App Router) | Server actions + RSC + edge-friendly |
| React | **19** | Concurrent + Suspense, async transitions |
| Styling | **Tailwind v4** | Native CSS variables, design-token first |
| Components | **shadcn/ui** | Owned source, no runtime cost |
| Editor | **Tiptap v2** | Best-in-class extensibility on ProseMirror |
| Forms | **React Hook Form + Zod** | Same Zod schemas as the API |
| State | RSC + URL state, then **Zustand** for client islands | Avoid global stores |

## Backend

| Layer | Choice | Rationale |
|---|---|---|
| HTTP | **Next.js Route Handlers** for public API | Same runtime as dashboard |
| Internal RPC | **tRPC** (server actions) | Type-safe dashboard mutations |
| ORM | **Drizzle** | Lightweight, SQL-first, great types |
| DB | **Postgres 16** | Versioned templates need relational integrity |
| Auth | **better-auth** | Self-hostable, owns its tables, no vendor lock |
| Queue | **BullMQ** + **Redis 7** | Battle-tested for async jobs |
| Templating | **Handlebars** | Mature, safe, supports `{{#if}}` / `{{#each}}` |
| Validation | **Zod** + auto-generated **JSON Schema** | One source of truth |

## Document rendering

| Layer | Choice | Rationale |
|---|---|---|
| PDF engine | **Puppeteer** + bundled Chromium | Full CSS fidelity |
| Serverless variant | **`@sparticuz/chromium`** | Compatible with Lambda/Vercel functions if needed |
| HTML templating | **Handlebars** with safe helpers | `format-date`, `format-currency`, `eq`, `gt`, `lt`, `each` |
| Storage SDK | **`@aws-sdk/client-s3`** | S3-compatible against MinIO (dev) / R2 (prod) |

## Embed mode (v0.2)

| Layer | Choice | Rationale |
|---|---|---|
| JWT signing | **EdDSA (Ed25519)** | Asymmetric, fast, modern |
| JWT library | **`jose`** | Standards-compliant, well-maintained |
| postMessage protocol | hand-rolled in `@craftkit/embed` | Tight control, no transitive deps |
| Iframe sandbox | `allow-scripts allow-forms allow-same-origin` | Narrowest viable |

## AI (v0.2 capstone)

| Layer | Choice | Rationale |
|---|---|---|
| Provider abstraction | **Vercel AI SDK** | Multi-provider, streaming, structured output |
| Default models | OpenAI `gpt-4.1-mini` for fast, `gpt-4.1` for high quality | Stable cost/quality |
| Fallback | Anthropic `claude-sonnet-4` | Diversification |
| Output format | Tiptap JSON via structured-output | LLM-cannot-corrupt invariant |

## Observability

| Layer | Choice | Rationale |
|---|---|---|
| Errors | **Sentry** | Standard, generous free tier |
| Product analytics | **PostHog** (self-hostable) | EU-friendly, owns data |
| Logs | **Axiom** or **Better Stack** | Cheaper than Datadog at our scale |
| Metrics | **OpenTelemetry** | Vendor-neutral |

## Testing

| Kind | Tool |
|---|---|
| Unit | **Vitest** |
| Component | **Vitest + Testing Library** |
| E2E | **Playwright** |
| API contracts | Vitest with real DB (Docker) |

## CI/CD

- **GitHub Actions** for CI
- Per-PR: typecheck, lint, unit, anti-residue scan
- Per-merge to main: integration tests, deploy preview
- Per-release tag: production deploy to Vercel + Railway

## What we deliberately did NOT choose

| Rejected | Why |
|---|---|
| Express / Fastify | Next.js Route Handlers cover it without a second runtime |
| Mongo / DynamoDB | Versioned templates + audit trails want relational |
| MJML for emails | Tiptap-based email blocks = same builder for everything |
| Clerk | Vendor lock; better-auth gives the same DX self-hostable |
| Mux / Cloudinary | Out of scope for v0.1–v0.3 |
| Prisma | Heavier than Drizzle, slower codegen |
| ESLint + Prettier | Biome is one tool, faster, less config |

---
_Last revised: 2026-05-02_


---
