Craftkitdocs

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 any form.submitted delivery that exhausts its retries (abandoned).

At-least-once, and no replay. The collect submit returns 202 only after the form.submitted delivery is durably enqueued — but a 503/500 from the submit endpoint may still have enqueued (and delivered) it, so treat form.submitted as at-least-once. A submit retry mints a new delivery, so x-craftkit-delivery-id won't match across a resubmit — dedupe on the payload's sessionId (stable per fill) rather than on the delivery id alone. And because an abandoned delivery 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 2xx response 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 abandoned and 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 2xx quickly 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.