Craftkitdocs

Integration guide

End-to-end implementation playbook: API rendering, builder embed, form embed, and production hardening.

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 → TemplatesNew 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 keysCreate 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

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

Verify connectivity:

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

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:

{
  "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

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' || render.status === 'cancelled') {
      throw new Error(`Render ${render.status}: ${render.error?.message}`);
    }
    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 → WebhooksAdd webhook URL → enter your endpoint.

// 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 (`sha256=${hmac}` !== sig) return res.status(401).end();

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

2.4 Handle errors

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 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 → OverviewEnable 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

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.

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

// 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

// 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

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

// 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 (`sha256=${hmac}` !== sig) return res.status(401).end();

  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

// 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

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.

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.

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 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

# 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
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