Guia do produto e da API

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 baixado
  • document.render.failed — um documento na fila não pôde ser produzido; o corpo contém failureCode
  • document.viewed — um documento compartilhado foi aberto no visualizador
  • document.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-EventO tipo de evento, por exemplo document.render.succeeded.
X-PDFgine-DeliveryO ID de entrega. O mesmo ID se repete quando uma chamada é refeita; use-o para tornar seu handler idempotente.
X-PDFgine-TimestampQuando a chamada foi assinada, em segundos Unix em UTC.
X-PDFgine-Signaturev1= seguido da assinatura em hexadecimal minúsculo.
User-AgentPDFgine-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.

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

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;

// 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);
});
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();
});

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.

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"
}

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.