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 → 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 againsthttps://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.devVerify 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 → Webhooks → Add 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 → 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.
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 includescope.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
catalogIdstored in your env vars -
renew_tokenstored server-side and rotated on every refresh -
refreshcallback 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_limitedand5xx(exponential backoff, max 5 attempts) -
errorevent handled in the builder and form with fallback UI forrecoverable: false
Webhooks
- HMAC signature verified on every webhook before processing
- Webhook endpoint responds within 10s (enqueue heavy work, don't block)
- Idempotency: use
jobIdfor renders, check for duplicatetemplateId + versionin your DB
Monitoring
- Alert on sustained
5xxrate from Craftkit endpoints - Track
session.expiredevents — indicatesrefreshis failing - Log
error.codefrom 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 |
Related
- Quickstart — render your first PDF in five minutes
- Builder embed — full builder reference
- Form-fill embeddable — full form reference
- Embed quickstart — minimal embed setup
- Variable catalog — field schema reference
- POST /v1/embed/catalogs — catalog API
- Authentication — key management and troubleshooting
- Errors — full error envelope and retry semantics