Product and API guide

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 downloaded
  • document.render.failed — a queued document could not be produced; the body carries failureCode
  • document.viewed — a shared document was opened in the viewer
  • document.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-EventThe event type, for example document.render.succeeded.
X-PDFgine-DeliveryThe delivery id. The same id repeats when a call is retried - use it to make your handler idempotent.
X-PDFgine-TimestampWhen the call was signed, as Unix seconds in UTC.
X-PDFgine-Signaturev1= followed by the signature in lower-case hexadecimal.
User-AgentPDFgine-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.

Scheme
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.

Node.js
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);
});
C#
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.

document.render.succeeded
{
  "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
  }
}
document.downloaded
{
  "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.