Skip to content
Get started

Webhooks

Webhooks tell your server when a render job finishes, so you don’t have to poll. Doquill sends a signed POST request to your endpoint for every subscribed event.

In the app, open Developers → Webhooks and click Add webhook. Enter the endpoint URL, pick the events, and copy the signing secret shown after saving. You need the manage_webhooks permission. Through the API, use POST /v1/webhooks; the response contains the secret, and GET /v1/webhooks/{id}/secret reveals it again later.

The URL must use https and point to a publicly reachable host. A workspace can have up to 10 endpoints.

Event Sent when
render.succeeded A render job finished and its file is stored
render.failed A render job failed for good, after any retries
webhook.ping You clicked Send test event, or called POST /v1/webhooks/{id}/test. Can’t be subscribed to.

Synchronous POST /v1/render calls don’t send events.

Every request body is a JSON envelope:

render.succeeded
{
"id": "2c4e7d8a-6f5b-5c7e-9a1d-3b8f2e6c4a10",
"type": "render.succeeded",
"created_at": "2026-10-09T12:00:03.512Z",
"data": {
"job_id": "0b6c4f7e-1d2a-4e3b-8c5d-6f7a8b9c0d1e",
"file_id": "5f1e2d3c-4b5a-4968-8776-655443322110",
"template_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"template_version": 3,
"filename": "INV-1042.pdf",
"content_type": "application/pdf",
"size": 48213,
"tags": ["customer:acme"]
}
}
render.failed
{
"id": "7d1a9e3b-2c4f-5a6b-8d7e-9f0a1b2c3d4e",
"type": "render.failed",
"created_at": "2026-10-09T12:00:03.512Z",
"data": {
"job_id": "0b6c4f7e-1d2a-4e3b-8c5d-6f7a8b9c0d1e",
"template_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"template_version": 3,
"error": "render failed: …"
}
}

Download the rendered file with GET /v1/files/{file_id}/content. The tags you set on the job come back in the event, which makes them a good place for your own IDs.

Respond with any 2xx status within 15 seconds to acknowledge a delivery. Anything else, including redirects, timeouts and connection errors, is a failed attempt.

Failed deliveries are retried with exponential backoff, starting at 30 seconds and doubling up to once an hour, for 24 hours. After that the delivery is marked failed. You can see every attempt, with your endpoint’s status code and response, on the endpoint’s page in the app, and redeliver an event from there or with POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver. Delivery history is kept for 30 days.

Do slow work after responding: acknowledge first, then process the event in the background.

The same event can arrive more than once: after a retry of an attempt that did reach you, after a redelivery, or if a job is reprocessed. The webhook-id header (equal to the envelope’s id) is the same every time, so record the IDs you’ve handled and skip repeats. Events can also arrive out of order; use created_at if order matters.

Anyone can send requests to your endpoint, so check that each one comes from Doquill before acting on it. Doquill signs webhooks following the Standard Webhooks specification. Each request carries three headers:

Header
webhook-id The event ID
webhook-timestamp When this attempt was sent, in Unix seconds
webhook-signature v1, followed by the base64 HMAC-SHA256 of {webhook-id}.{webhook-timestamp}.{body}, keyed with the base64-decoded part of your secret after whsec_. May contain several space-separated signatures.

The easiest way to verify is with the official standardwebhooks library for your language. Pass it the secret and the raw request body. Parsing the JSON and re-serialising it changes the bytes and breaks the signature.

npm install standardwebhooks
verify.ts
import { Webhook } from "standardwebhooks";
// Returns the parsed event, or throws if the signature or timestamp is invalid.
// `body` must be the raw request body, exactly as received.
export function verifyWebhook(secret: string, headers: Record<string, string>, body: string) {
return new Webhook(secret).verify(body, headers);
}

With Express, read the body raw for this route:

app.post("/webhooks/doquill", express.text({ type: "application/json" }), (req, res) => {
let event;
try {
event = verifyWebhook(process.env.DOQUILL_WEBHOOK_SECRET!, req.headers as Record<string, string>, req.body);
} catch {
return res.sendStatus(400);
}
res.sendStatus(204);
// handle event…
});

The check is short enough to write yourself. Compare signatures in constant time, and reject timestamps more than five minutes away from your clock to stop replays of old requests.

verify-manual.ts
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
// Returns the parsed event, or throws if the signature or timestamp is invalid.
// `body` must be the raw request body, exactly as received.
export function verifyWebhook(secret: string, headers: Record<string, string>, body: string) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) {
throw new Error("missing webhook headers");
}
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE_SECONDS) {
throw new Error("webhook timestamp outside tolerance");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
// The header can carry several space-separated signatures, e.g. during secret rotation.
for (const entry of signatures.split(" ")) {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) continue;
const received = Buffer.from(signature, "base64");
if (received.length === expected.length && timingSafeEqual(received, expected)) {
return JSON.parse(body) as unknown;
}
}
throw new Error("no matching webhook signature");
}

Click Send test event on the endpoint’s page, or call POST /v1/webhooks/{id}/test, to send a signed webhook.ping. Its delivery and your endpoint’s response show up in the delivery list.