Tokoman

Developers

Webhooks

Register an endpoint and Tokoman tells you when a document is ready, signed, with the link.

bash
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

EventWhen
document.completedA PDF exists and can be downloaded
document.failedRendering did not produce a PDF; the payload says why
template.activatedA version became the one that generates
wallet.creditedA top-up arrived
wallet.debitedPages beyond the plan were charged

What arrives

a delivery
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.

receiver.js
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

bash
POST /v1/webhooks/:id/ping

Sends a signed ping delivery right now and answers with what your endpoint said.