Webhooks
O que é enviado
O PDFgine faz callbacks por dois motivos: a fila avisa quando termina de produzir um documento, e o visualizador avisa quando a pessoa com quem você compartilhou um documento o abre ou baixa.
document.render.succeeded— um documento na fila foi produzido e pode ser baixadodocument.render.failed— um documento na fila não pôde ser produzido; o corpo contém failureCodedocument.viewed— um documento compartilhado foi aberto no visualizadordocument.downloaded— um documento compartilhado foi baixado
Eventos de renderização vão para a webhookUrl informada na solicitação. Eventos do visualizador vão para os endpoints registrados uma vez com POST /api/v1/engagement-webhooks; cada endpoint informa os eventos desejados.
Cabeçalhos de cada chamada
Os dois tipos de webhook possuem os mesmos cabeçalhos, então uma única rotina de verificação cobre tudo.
| Cabeçalho | O que contém |
|---|---|
X-PDFgine-Event | O tipo de evento, por exemplo document.render.succeeded. |
X-PDFgine-Delivery | O ID de entrega. O mesmo ID se repete quando uma chamada é refeita; use-o para tornar seu handler idempotente. |
X-PDFgine-Timestamp | Quando a chamada foi assinada, em segundos Unix em UTC. |
X-PDFgine-Signature | v1= seguido da assinatura em hexadecimal minúsculo. |
User-Agent | PDFgine-Webhooks/1.0 |
Como a assinatura é construída
A assinatura cobre o timestamp e o corpo juntos, então uma chamada capturada não pode ser reproduzida depois com um novo timestamp.
X-PDFgine-Signature: v1=<hex>
hex = HMAC-SHA256(
key = <the secret you registered>,
message = <X-PDFgine-Timestamp> + "." + <raw request body>
) // lower-case hexadecimal
Assine os bytes brutos recebidos, nunca um objeto serializado novamente. Um parser JSON pode reordenar chaves ou alterar espaços, e a assinatura nunca corresponderá; esta é a causa mais comum de falha na verificação.
Tolerância de timestamp
O PDFgine não decide sua tolerância; você decide. Rejeite uma chamada cujo timestamp esteja a mais de cinco minutos do seu relógio: margem suficiente para desvios normais e novas tentativas, mas curta o bastante para inutilizar rapidamente uma chamada capturada.
Compare as duas assinaturas em tempo constante. Uma comparação comum de strings retorna mais cedo se o primeiro caractere estiver errado do que se for o último, e essa diferença é mensurável.
Verificação pronta para executar
Os dois exemplos fazem as mesmas quatro coisas: leem o corpo bruto, verificam a idade do timestamp, recalculam a assinatura e a comparam em tempo constante.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.PDFGINE_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
// O corpo BRUTO é obrigatório: express.json() quebra a assinatura porque reescreve o 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();
});
Formato do corpo
Os eventos de renderização incluem um envelope com o ID de entrega, o tipo de evento e um objeto data. Os eventos do visualizador incluem os campos diretamente.
{
"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"
}
Atualmente, os dois formatos diferem de propósito, e é bom saber disso antes de escrever o handler: nos eventos do visualizador, o tipo de evento está apenas no cabeçalho X-PDFgine-Event, não no corpo.
Entrega e novas tentativas
Um webhook é enfileirado, não enviado na mesma execução; assim, um endpoint lento não atrasa a produção de documentos.
- Qualquer resposta 2xx conta como entregue. Qualquer outra é falha, inclusive um redirecionamento.
- Até 6 tentativas, com espera exponencial a partir de 15 segundos.
- Uma chamada sem resposta em 15 segundos é considerada falha e refeita.
- O destino deve usar https e resolver para um endereço público. localhost, nomes .local e faixas privadas são recusados no registro do endpoint e novamente antes de cada chamada.
Como uma nova tentativa repete o mesmo ID X-PDFgine-Delivery, registre os IDs já processados e responda 2xx às repetições sem executar o trabalho duas vezes.
Erros →Os problemas mais comuns
- Verificar um corpo serializado novamente nunca corresponde. Frameworks que analisam JSON reordenam chaves e alteram espaços; guarde os bytes brutos antes de qualquer alteração.
- Apenas 2xx conta como entregue. Um 301 ou 302 para o handler real é falha e a chamada é refeita; aponte o endpoint para o endereço final.
- Verificar a assinatura sem o timestamp permite reproduzir uma chamada capturada para sempre. Rejeite qualquer chamada mais antiga que sua tolerância.
- Uma nova tentativa repete o mesmo ID X-PDFgine-Delivery. Um handler que refaz o trabalho envia a mesma fatura duas vezes; registre os IDs processados.
- Um endpoint registrado deve usar https e ser resolvido publicamente. localhost e faixas privadas são recusados no registro; teste por um túnel em vez de insistir no endereço recusado.