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.
Adding an endpoint
Section titled “Adding an endpoint”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.
Events
Section titled “Events”| 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:
{ "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"] }}{ "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.
Responding and retries
Section titled “Responding and retries”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.
Duplicates and ordering
Section titled “Duplicates and ordering”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.
Verifying signatures
Section titled “Verifying signatures”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 standardwebhooksimport { 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…});pip install standardwebhooksfrom standardwebhooks.webhooks import Webhook
def verify_webhook(secret: str, headers: dict[str, str], body: bytes): """Return the parsed event, or raise if the signature or timestamp is invalid.
`body` must be the raw request body, exactly as received. """ return Webhook(secret).verify(body, headers)With Flask, use request.get_data() for the raw body:
@app.post("/webhooks/doquill")def doquill_webhook(): try: event = verify_webhook(os.environ["DOQUILL_WEBHOOK_SECRET"], dict(request.headers), request.get_data()) except Exception: return "", 400 # handle event… return "", 204go get github.com/standard-webhooks/standard-webhooks/librariespackage main
import ( "net/http"
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go")
// verifyWebhook returns an error if the signature or timestamp is invalid.// body must be the raw request body, exactly as received.func verifyWebhook(secret string, headers http.Header, body []byte) error { wh, err := standardwebhooks.NewWebhook(secret) if err != nil { return err } return wh.Verify(body, headers)}In a handler:
func handleWebhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil || verifyWebhook(os.Getenv("DOQUILL_WEBHOOK_SECRET"), r.Header, body) != nil { http.Error(w, "invalid webhook", http.StatusBadRequest) return } w.WriteHeader(http.StatusNoContent) // handle body…}Without a library
Section titled “Without a library”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.
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");}import base64import hashlibimport hmacimport jsonimport time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(secret: str, headers: dict[str, str], body: bytes): """Return the parsed event, or raise if the signature or timestamp is invalid.
`body` must be the raw request body, exactly as received. """ headers = {k.lower(): v for k, v in headers.items()} msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not (msg_id and timestamp and signatures): raise ValueError("missing webhook headers")
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: raise ValueError("webhook timestamp outside tolerance")
key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
# The header can carry several space-separated signatures, e.g. during secret rotation. for entry in signatures.split(" "): version, _, signature = entry.partition(",") if version == "v1" and hmac.compare_digest(signature, expected): return json.loads(body) raise ValueError("no matching webhook signature")package main
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "errors" "net/http" "strconv" "strings" "time")
const tolerance = 5 * time.Minute
// verifyWebhookManual returns an error if the signature or timestamp is invalid.// body must be the raw request body, exactly as received.func verifyWebhookManual(secret string, headers http.Header, body []byte) error { id := headers.Get("webhook-id") timestamp := headers.Get("webhook-timestamp") signatures := headers.Get("webhook-signature") if id == "" || timestamp == "" || signatures == "" { return errors.New("missing webhook headers") }
ts, err := strconv.ParseInt(timestamp, 10, 64) if err != nil { return errors.New("invalid webhook timestamp") } if age := time.Since(time.Unix(ts, 0)); age > tolerance || age < -tolerance { return errors.New("webhook timestamp outside tolerance") }
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return err } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + timestamp + ".")) mac.Write(body) expected := mac.Sum(nil)
// The header can carry several space-separated signatures, e.g. during secret rotation. for _, entry := range strings.Split(signatures, " ") { version, signature, _ := strings.Cut(entry, ",") received, err := base64.StdEncoding.DecodeString(signature) if version == "v1" && err == nil && hmac.Equal(received, expected) { return nil } } return errors.New("no matching webhook signature")}Testing your endpoint
Section titled “Testing your endpoint”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.