Webhooks
What gets delivered
PDFgine calls you back for two different things: the queue tells you when a document it was producing is finished, and the viewer tells you when the person you shared a document with opened or downloaded it.
document.render.succeeded— a queued document was produced and can be downloadeddocument.render.failed— a queued document could not be produced; the body carries failureCodedocument.viewed— a shared document was opened in the viewerdocument.downloaded— a shared document was downloaded
Render events go to the webhookUrl you pass with the render request. Viewer events go to the endpoints you register once with POST /api/v1/engagement-webhooks; each endpoint names the events it wants.
Headers on every call
Both kinds of webhook carry the same headers, so one verification routine covers everything.
| Header | What it carries |
|---|---|
X-PDFgine-Event | The event type, for example document.render.succeeded. |
X-PDFgine-Delivery | The delivery id. The same id repeats when a call is retried - use it to make your handler idempotent. |
X-PDFgine-Timestamp | When the call was signed, as Unix seconds in UTC. |
X-PDFgine-Signature | v1= followed by the signature in lower-case hexadecimal. |
User-Agent | PDFgine-Webhooks/1.0 |
How the signature is built
The signature covers the timestamp and the body together, so a captured call cannot be replayed later with a fresh timestamp.
X-PDFgine-Signature: v1=<hex>
hex = HMAC-SHA256(
key = <the secret you registered>,
message = <X-PDFgine-Timestamp> + "." + <raw request body>
) // lower-case hexadecimal
Sign the raw bytes you received, never a re-serialised object. A JSON parser may reorder keys or change spacing, and the signature will then never match - this is the single most common reason verification fails.
Timestamp tolerance
PDFgine does not decide your tolerance; you do. Reject a call whose timestamp is further than five minutes from your own clock - wide enough for normal clock drift and retries, narrow enough that a captured call becomes useless quickly.
Compare the two signatures in constant time. A plain string comparison returns sooner for a wrong first character than for a wrong last one, and that difference is measurable.
Verification, ready to run
Both examples do the same four things: read the raw body, check the age of the timestamp, recompute the signature and compare it in constant time.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.PDFGINE_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
// The RAW body is required: express.json() breaks the signature, because it rewrites the JSON.
app.post("/hooks/pdfgine", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-PDFgine-Timestamp");
const header = req.get("X-PDFgine-Signature") ?? "";
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!timestamp || Number.isNaN(age) || age > TOLERANCE_SECONDS) {
return res.sendStatus(400);
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${req.body.toString("utf8")}`)
.digest("hex");
const supplied = header.startsWith("v1=") ? header.slice(3) : "";
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(supplied, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
console.log(req.get("X-PDFgine-Event"), req.get("X-PDFgine-Delivery"), event);
res.sendStatus(204);
});
app.MapPost("/hooks/pdfgine", async (HttpRequest request) =>
{
const int toleranceSeconds = 300;
var secret = Environment.GetEnvironmentVariable("PDFGINE_WEBHOOK_SECRET")!;
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync();
var timestampHeader = request.Headers["X-PDFgine-Timestamp"].ToString();
if (!long.TryParse(timestampHeader, out var timestamp)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > toleranceSeconds)
{
return Results.BadRequest();
}
var expected = Convert.ToHexString(HMACSHA256.HashData(
Encoding.UTF8.GetBytes(secret),
Encoding.UTF8.GetBytes($"{timestamp}.{body}")))
.ToLowerInvariant();
var header = request.Headers["X-PDFgine-Signature"].ToString();
var supplied = header.StartsWith("v1=", StringComparison.Ordinal) ? header[3..] : "";
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(supplied)))
{
return Results.Unauthorized();
}
return Results.NoContent();
});
What the body looks like
Render events carry an envelope with the delivery id, the event type and a data object. Viewer events carry the fields directly.
{
"id": "evt_8f1c4a7d6b2e4f0a91c3d5e7f9a1b3c5",
"type": "document.render.succeeded",
"createdAt": "2026-09-26T14:39:45+00:00",
"data": {
"queueId": "queue_fe86ad38402a4909b3b5a7965847924a",
"batchId": "batch_69c4ecb63f8642628b72bca89d4f280c",
"status": "succeeded",
"templateCode": "invoice",
"format": "pdf",
"renderJobId": "job_bfae00cc59354089bffd41fa298377b2",
"documentId": "doc_a96063f5dcd5400286ce9b3be9b0595b",
"failureCode": null
}
}
{
"previewId": "prv_510ff800bbcd4f848b0b769cf37ad4c2",
"templateCode": "invoice",
"deviceClass": "desktop",
"accessedAt": "2026-09-26T14:41:02+00:00",
"format": "pdf"
}
The two shapes differ on purpose today, and it is worth knowing before you write the handler: for viewer events the event type is only in the X-PDFgine-Event header, not in the body.
Delivery and retries
A webhook is queued, not sent inline, so a slow endpoint of yours never slows down document production.
- Any 2xx counts as delivered. Anything else is a failure, including a redirect.
- Up to 6 attempts, backing off exponentially from 15 seconds.
- A call that has not answered within 15 seconds is treated as failed and retried.
- The destination must be https and must resolve to a public address. localhost, .local names and private ranges are refused when the endpoint is registered and again before every call.
Because a retry repeats the same X-PDFgine-Delivery id, record the ids you have already handled and answer 2xx to a repeat without doing the work twice.
Errors →What usually goes wrong
- Verifying against a re-serialised body never matches. Frameworks that parse JSON for you reorder keys and change spacing; keep the raw bytes before anything touches them.
- Only 2xx counts as delivered. A 301 or 302 to your real handler is a failure and the call is retried - point the endpoint at the final address.
- Checking the signature but not the timestamp leaves a captured call replayable forever. Reject anything older than your tolerance.
- A retry repeats the same X-PDFgine-Delivery id. A handler that does the work again sends the same invoice twice; record the ids you have handled.
- A registered endpoint must be https and publicly resolvable. localhost and private ranges are refused at registration, so test through a tunnel rather than fighting the refusal.