Developers
Webhooks
Register an endpoint and Tokoman tells you when a document is ready, signed, with the link.
POST /v1/webhooks
{ "name": "Billing system", "url": "https://billing.example.co.za/hooks/tokoman", "events": ["document.completed", "document.failed"] }The response carries the signing secret once. The URL must be https and must resolve to a public address. An empty events list subscribes to everything.
Events
| Event | When |
|---|---|
| document.completed | A PDF exists and can be downloaded |
| document.failed | Rendering did not produce a PDF; the payload says why |
| template.activated | A version became the one that generates |
| wallet.credited | A top-up arrived |
| wallet.debited | Pages beyond the plan were charged |
What arrives
POST https://billing.example.co.za/hooks/tokoman
Content-Type: application/json
x-tokoman-event: document.completed
x-tokoman-delivery: 6f0c… (stable across retries)
x-tokoman-signature: t=1789125548,v1=9f2c…
{
"v": 1,
"id": "6f0c…",
"event": "document.completed",
"orgId": "…",
"at": "2026-09-12T10:11:58.000Z",
"data": {
"id": "8c1f…", "status": "completed", "templateSlug": "invoice", "templateVersion": 1,
"correlationId": "INV-2026-0418", "pageCount": 2, "byteSize": 22330,
"url": "https://…", "urlExpiresAt": "2026-09-13T10:11:58.000Z",
"filename": "invoice-INV-2026-0418.pdf",
"metadata": { "customerId": "KBF-0091" }
}
}The data you rendered from is never included. You already have it, and it may be personal.
Verifying the signature
The signed string is the timestamp, a dot, and the raw body exactly as received. Compute an HMAC-SHA256 with your secret and compare in constant time. Refuse anything older than five minutes.
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (age > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(parts.v1, "hex"), Buffer.from(expected, "hex"));
}
// Express: mount on a RAW body parser, before express.json()
app.post("/hooks/tokoman", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body.toString(), req.get("x-tokoman-signature"), process.env.TOKOMAN_WEBHOOK_SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString());
// handle it, then answer 2xx
res.sendStatus(200);
});Retries
A 2xx means delivered. A 3xx, or a 4xx other than 408 and 429, is a permanent failure. A 5xx or a network error is retried up to six times with increasing, jittered delays over about an hour. The x-tokoman-delivery header is the same on every attempt, so deduplicate on it. After twenty consecutive failures an endpoint is switched off and stays listed so somebody can see why.
Testing
POST /v1/webhooks/:id/pingSends a signed ping delivery right now and answers with what your endpoint said.