Webhooks
Qué se envía
PDFgine te llama por dos motivos distintos: la cola avisa cuando termina de generar un documento y el visor avisa cuando la persona con la que compartiste un documento lo abre o descarga.
document.render.succeeded— se ha generado un documento en cola y se puede descargardocument.render.failed— no se pudo generar un documento en cola; el cuerpo incluye failureCodedocument.viewed— se ha abierto un documento compartido en el visordocument.downloaded— se ha descargado un documento compartido
Los eventos de renderización se envían al webhookUrl de la solicitud de renderización. Los del visor se envían a los endpoints que registres una vez con POST /api/v1/engagement-webhooks; cada endpoint indica los eventos que desea recibir.
Cabeceras de cada llamada
Ambos tipos de webhook incluyen las mismas cabeceras, por lo que una sola rutina de verificación sirve para todos.
| Encabezado | Qué contiene |
|---|---|
X-PDFgine-Event | El tipo de evento, por ejemplo document.render.succeeded. |
X-PDFgine-Delivery | El ID de entrega. Se repite el mismo ID al reintentar una llamada; úsalo para que tu controlador sea idempotente. |
X-PDFgine-Timestamp | Cuándo se firmó la llamada, en segundos Unix UTC. |
X-PDFgine-Signature | v1= seguido de la firma en hexadecimal en minúsculas. |
User-Agent | PDFgine-Webhooks/1.0 |
Cómo se construye la firma
La firma abarca a la vez la marca de tiempo y el cuerpo, por lo que una llamada capturada no puede repetirse después con una marca de tiempo nueva.
X-PDFgine-Signature: v1=<hex>
hex = HMAC-SHA256(
key = <the secret you registered>,
message = <X-PDFgine-Timestamp> + "." + <raw request body>
) // lower-case hexadecimal
Firma los bytes originales que recibas, nunca un objeto vuelto a serializar. Un analizador JSON puede reordenar claves o cambiar los espacios y la firma nunca coincidirá; esta es la causa más común de fallos de verificación.
Tolerancia de tiempo
PDFgine no decide tu tolerancia; la decides tú. Rechaza una llamada cuya marca de tiempo se aleje más de cinco minutos de tu reloj: un margen suficiente para los desfases y reintentos normales, pero limitado para inutilizar pronto una llamada capturada.
Compara las dos firmas en tiempo constante. Una comparación de cadenas normal termina antes si el primer carácter es incorrecto que si lo es el último, y esa diferencia se puede medir.
Verificación lista para ejecutar
Ambos ejemplos hacen las mismas cuatro cosas: leen el cuerpo original, comprueban la antigüedad de la marca de tiempo, recalculan la firma y la comparan en tiempo constante.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.PDFGINE_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
// Se requiere el cuerpo ORIGINAL: express.json() rompe la firma porque reescribe el 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();
});
Estructura del cuerpo
Los eventos de renderización incluyen una envoltura con el ID de entrega, el tipo de evento y un objeto data. Los eventos del visor incluyen los campos directamente.
{
"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"
}
Actualmente, ambas estructuras son diferentes de forma intencionada y conviene saberlo antes de escribir el controlador: en los eventos del visor, el tipo de evento solo está en la cabecera X-PDFgine-Event, no en el cuerpo.
Entrega y reintentos
Un webhook se encola, no se envía dentro de la solicitud, por lo que un endpoint lento nunca retrasa la generación de documentos.
- Cualquier respuesta 2xx cuenta como entregada. Todo lo demás es un fallo, incluidas las redirecciones.
- Hasta 6 intentos, con esperas exponenciales a partir de 15 segundos.
- Si una llamada no responde en 15 segundos, se considera fallida y se reintenta.
- El destino debe usar https y resolverse a una dirección pública. localhost, los nombres .local y los rangos privados se rechazan al registrar el endpoint y de nuevo antes de cada llamada.
Como un reintento repite el mismo ID X-PDFgine-Delivery, registra los ID ya procesados y responde 2xx a los repetidos sin realizar el trabajo dos veces.
Errores →Los errores más habituales
- Verificar un cuerpo vuelto a serializar nunca produce una coincidencia. Los frameworks que analizan JSON reordenan claves y cambian espacios; conserva los bytes originales antes de que nada los modifique.
- Solo 2xx cuenta como entregado. Un 301 o 302 al controlador real es un fallo y la llamada se reintenta; configura el endpoint con la dirección final.
- Comprobar la firma sin comprobar la marca de tiempo permite repetir una llamada capturada indefinidamente. Rechaza cualquier llamada más antigua que tu tolerancia.
- Un reintento repite el mismo ID X-PDFgine-Delivery. Un controlador que haga el trabajo otra vez enviará la misma factura dos veces; registra los ID procesados.
- Un endpoint registrado debe usar https y resolverse públicamente. localhost y los rangos privados se rechazan al registrarlo; prueba a través de un túnel en lugar de combatir el rechazo.