Webhooks
Receive signed outgoing webhooks for render and signature lifecycle events, and verify them.
Craftkit pushes outgoing webhooks to your server as renders and signature requests change state, so you don't have to poll. Deliveries are 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.
Outgoing delivery webhooks
Create a webhook subscription in the dashboard under Project → Webhooks. A subscription has:
| Field | Description |
|---|---|
url |
Your HTTPS endpoint. Craftkit POSTs each event here. |
secret |
The HMAC-SHA256 signing secret. Used to compute x-craftkit-signature. |
events |
The events this subscription receives. New subscriptions default to render.succeeded and render.failed. |
A subscription only receives the events it is subscribed to. You can run several subscriptions per project (e.g. separate endpoints for render vs. signature events).
Events
| Event | Fired when |
|---|---|
render.succeeded |
A render finished and the PDF is available. |
render.failed |
A render failed. |
signature.sent |
A signature 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. |
Delivery format
Each delivery is a POST with Content-Type: application/json and these headers:
| Header | Description |
|---|---|
x-craftkit-event |
The event name (e.g. render.succeeded). |
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. |
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. |
The body always carries an event field. Render events look like:
{
"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"
}
downloadUrlis populated only when public delivery is configured (S3_PUBLIC_URL); otherwise it isnulland you fetch the PDF via the authenticated download route.
Signature events carry the signature request id and its render id:
{
"event": "signature.completed",
"signatureRequestId": "0193c2c3-2222-7aaa-8bbb-000000000002",
"renderId": "0193c2c3-1111-7aaa-8bbb-000000000001",
"status": "completed"
}Verifying the signature
Recompute the HMAC over the exact raw body bytes you received (do not re-serialize the parsed JSON) and compare it to x-craftkit-signature in constant time.
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 event = JSON.parse(req.body.toString('utf8'));
// ...handle event.event...
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 request 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. - 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 timeout and trigger needless retries.
Related
- Digital signatures — send documents for signature and emit
signature.*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