Craftkitdocs

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

downloadUrl is populated only when public delivery is configured (S3_PUBLIC_URL); otherwise it is null and 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 2xx response 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 2xx quickly and do heavy work asynchronously, so a slow handler doesn't trip the timeout and trigger needless retries.