Webhooks
Receive signed outgoing webhooks for render, document-engagement, and signature lifecycle events, and verify them — event triggers, per-family payload structures, and retries.
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 (
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 POSTs 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 and 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 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). 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 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 anyform.submitteddelivery that exhausts its retries (abandoned).
At-least-once, and no replay. The collect submit returns
202only after theform.submitteddelivery is durably enqueued — but a503/500from the submit endpoint may still have enqueued (and delivered) it, so treatform.submittedas at-least-once. A submit retry mints a new delivery, sox-craftkit-delivery-idwon't match across a resubmit — dedupe on the payload'ssessionId(stable per fill) rather than on the delivery id alone. And because anabandoneddelivery 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 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. |
{
"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. |
{
"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. |
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).
{
"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 2xxs it, then purged (see the ephemeral note above).
{
"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
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
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
2xxresponse 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
abandonedand 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
2xxquickly 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 — send documents for signature and emit
signature.*events - Shares & delivery and Engagement — the source of
document.*events - GET /v1/renders/:id — the poll-based alternative to
render.*webhooks - Inbound render webhook — trigger a render from an external system
- Errors — error envelope and codes