Skip to main content
Instead of polling, subscribe a webhook so Craftkit pushes the result to your server when a render reaches a terminal state. This is ideal for offline-replay flows: the render fires on reconnect, and you attach the link asynchronously when the callback arrives.

Configure a subscription

Webhooks are configured per project in the dashboard (Project → Webhooks): a URL, the events to subscribe to, and an active flag. There is no API to register webhooks programmatically yet (roadmap) — use the dashboard. Delivered events:
EventFired when
render.succeededa render finished and the PDF is available
render.faileda render terminated with an error

Delivery contract

Craftkit POSTs a JSON body to your URL with these headers:
HeaderValue
x-craftkit-eventthe event name (render.succeeded / render.failed)
x-craftkit-signatureHMAC-SHA256 of the raw body, hex-encoded, keyed by the subscription secret
x-craftkit-timestampunix seconds when the delivery was signed
x-craftkit-delivery-idunique delivery id (use for your own idempotency)
user-agentCraftkit-Webhook/1.0
Body:
{
  "event": "render.succeeded",
  "renderId": "…",
  "templateId": "…",
  "status": "succeeded",
  "downloadUrl": "https://…/renders/…/….pdf",
  "errorMessage": null,
  "createdAt": "2026-06-05T10:00:00.000Z",
  "completedAt": "2026-06-05T10:00:03.120Z"
}

Verify the signature

Compute the HMAC over the raw request body and compare against x-craftkit-signature (raw hex, no sha256= prefix). Reject on mismatch.
import { createHmac, timingSafeEqual } from 'node:crypto';

app.post('/webhooks/craftkit', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = createHmac('sha256', process.env.CK_WEBHOOK_SECRET)
    .update(req.body) // raw Buffer
    .digest('hex');
  const got = req.header('x-craftkit-signature') ?? '';
  const ok =
    expected.length === got.length &&
    timingSafeEqual(Buffer.from(expected), Buffer.from(got));
  if (!ok) return res.status(401).end();

  const event = JSON.parse(req.body.toString());
  if (event.event === 'render.succeeded') {
    storeDownloadUrl(event.renderId, event.downloadUrl);
  }
  res.json({ received: true }); // return 2xx promptly
});

Retries

Failed deliveries (non-2xx, timeout, or error) retry up to 6 attempts; after that the delivery is marked abandoned. Respond 2xx quickly (within ~15s) and do heavy work asynchronously. De-duplicate on x-craftkit-delivery-id since a delivery may arrive more than once.
downloadUrl in the payload follows the same durability rules as elsewhere — a permanent public URL, or null if no public bucket is configured. See Render lifecycle.