Webhooks
Contenu envoyé
PDFgine vous rappelle pour deux raisons : la file vous avertit lorsqu’un document qu’elle produisait est terminé, et la visionneuse vous avertit lorsque le destinataire d’un document partagé l’ouvre ou le télécharge.
document.render.succeeded— un document en file a été produit et peut être téléchargédocument.render.failed— un document en file n’a pas pu être produit ; le corps contient failureCodedocument.viewed— un document partagé a été ouvert dans la visionneusedocument.downloaded— un document partagé a été téléchargé
Les événements de rendu sont envoyés au webhookUrl fourni dans la requête de rendu. Les événements de la visionneuse sont envoyés aux points de terminaison enregistrés une fois avec POST /api/v1/engagement-webhooks ; chacun précise les événements souhaités.
En-têtes de chaque appel
Les deux types de webhook utilisent les mêmes en-têtes ; une seule routine de vérification couvre donc tous les cas.
| En-tête | Contenu |
|---|---|
X-PDFgine-Event | Le type d’événement, par exemple document.render.succeeded. |
X-PDFgine-Delivery | L’ID de livraison. Le même ID est répété lorsqu’un appel est réessayé ; utilisez-le pour rendre votre gestionnaire idempotent. |
X-PDFgine-Timestamp | L’instant de signature de l’appel, en secondes Unix UTC. |
X-PDFgine-Signature | v1= suivi de la signature en hexadécimal minuscule. |
User-Agent | PDFgine-Webhooks/1.0 |
Construction de la signature
La signature couvre ensemble l’horodatage et le corps ; un appel intercepté ne peut donc pas être rejoué plus tard avec un nouvel horodatage.
X-PDFgine-Signature: v1=<hex>
hex = HMAC-SHA256(
key = <the secret you registered>,
message = <X-PDFgine-Timestamp> + "." + <raw request body>
) // lower-case hexadecimal
Signez les octets bruts reçus, jamais un objet sérialisé à nouveau. Un analyseur JSON peut réordonner les clés ou modifier les espaces ; la signature ne correspondra alors jamais. C’est la cause la plus fréquente des échecs de vérification.
Tolérance d’horodatage
PDFgine ne choisit pas votre tolérance ; vous la définissez. Rejetez un appel dont l’horodatage s’écarte de plus de cinq minutes de votre horloge : assez pour les dérives normales et les nouvelles tentatives, mais suffisamment court pour rendre rapidement inutilisable un appel intercepté.
Comparez les deux signatures en temps constant. Une comparaison ordinaire de chaînes se termine plus vite si le premier caractère est incorrect que si c’est le dernier, et cette différence est mesurable.
Vérification prête à l’emploi
Les deux exemples effectuent les mêmes quatre opérations : lire le corps brut, vérifier l’ancienneté de l’horodatage, recalculer la signature et la comparer en temps constant.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.PDFGINE_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
// Le corps BRUT est requis : express.json() invalide la signature car il réécrit le 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();
});
Structure du corps
Les événements de rendu contiennent une enveloppe avec l’ID de livraison, le type d’événement et un objet data. Les événements de la visionneuse contiennent directement les champs.
{
"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"
}
Les deux structures diffèrent volontairement aujourd’hui. À savoir avant d’écrire le gestionnaire : pour les événements de la visionneuse, le type d’événement figure uniquement dans l’en-tête X-PDFgine-Event, pas dans le corps.
Livraison et nouvelles tentatives
Un webhook est mis en file et non envoyé pendant la requête ; un point de terminaison lent ne ralentit donc jamais la production de documents.
- Toute réponse 2xx vaut livraison réussie. Toute autre réponse est un échec, y compris une redirection.
- Jusqu’à 6 tentatives, avec un délai exponentiel à partir de 15 secondes.
- Un appel sans réponse dans les 15 secondes est considéré en échec et réessayé.
- La destination doit utiliser https et se résoudre en une adresse publique. localhost, les noms .local et les plages privées sont refusés lors de l’enregistrement du point de terminaison, puis avant chaque appel.
Une nouvelle tentative reprend le même ID X-PDFgine-Delivery ; enregistrez les ID déjà traités et répondez 2xx aux répétitions sans exécuter le travail deux fois.
Erreurs →Les problèmes les plus courants
- Vérifier un corps sérialisé à nouveau ne correspond jamais. Les frameworks qui analysent le JSON réordonnent les clés et modifient les espaces ; conservez les octets bruts avant toute manipulation.
- Seul 2xx compte comme une livraison réussie. Un 301 ou 302 vers votre vrai gestionnaire est un échec et l’appel est réessayé ; utilisez l’adresse finale.
- Vérifier la signature sans l’horodatage laisse un appel intercepté réutilisable indéfiniment. Rejetez tout appel plus ancien que votre tolérance.
- Une nouvelle tentative reprend le même ID X-PDFgine-Delivery. Un gestionnaire qui refait le travail envoie la même facture deux fois ; enregistrez les ID traités.
- Un point de terminaison enregistré doit utiliser https et être résolu publiquement. localhost et les plages privées sont refusés ; testez via un tunnel plutôt que d’insister sur une adresse refusée.