Guida al prodotto e alle API

Webhook

Cosa viene inviato

PDFgine ti richiama per due motivi: la coda segnala quando un documento in produzione è pronto e il visualizzatore segnala quando la persona con cui hai condiviso un documento lo apre o lo scarica.

  • document.render.succeeded — un documento in coda è stato prodotto e può essere scaricato
  • document.render.failed — un documento in coda non è stato prodotto; il corpo contiene failureCode
  • document.viewed — un documento condiviso è stato aperto nel visualizzatore
  • document.downloaded — un documento condiviso è stato scaricato

Gli eventi di generazione vengono inviati alla webhookUrl passata nella richiesta di generazione. Gli eventi del visualizzatore vanno agli endpoint registrati una volta con POST /api/v1/engagement-webhooks; ogni endpoint indica gli eventi desiderati.

Intestazioni di ogni chiamata

Entrambi i tipi di webhook contengono le stesse intestazioni, quindi una sola routine di verifica copre tutto.

Intestazione Cosa contiene
X-PDFgine-EventIl tipo di evento, ad esempio document.render.succeeded.
X-PDFgine-DeliveryL'ID di invio. Lo stesso ID si ripete nei tentativi successivi: usalo per rendere il gestore idempotente.
X-PDFgine-TimestampL'istante della firma, in secondi Unix UTC.
X-PDFgine-Signaturev1= seguito dalla firma in esadecimale minuscolo.
User-AgentPDFgine-Webhooks/1.0

Come viene creata la firma

La firma copre insieme timestamp e corpo, quindi una chiamata intercettata non può essere riprodotta in seguito con un timestamp nuovo.

Schema
X-PDFgine-Signature: v1=<hex>

hex = HMAC-SHA256(
        key     = <the secret you registered>,
        message = <X-PDFgine-Timestamp> + "." + <raw request body>
      )            // lower-case hexadecimal

Firma i byte grezzi ricevuti, mai un oggetto serializzato di nuovo. Un parser JSON può riordinare le chiavi o cambiare gli spazi, e la firma non coinciderà più: è la causa più comune degli errori di verifica.

Tolleranza del timestamp

La tolleranza la decidi tu, non PDFgine. Rifiuta le chiamate con un timestamp distante più di cinque minuti dal tuo orologio: abbastanza per normali scarti e nuovi tentativi, ma non per lasciare a lungo utilizzabile una chiamata intercettata.

Confronta le due firme in tempo costante. Un semplice confronto tra stringhe termina prima se è errato il primo carattere rispetto all'ultimo, e la differenza è misurabile.

Verifica pronta all'uso

Entrambi gli esempi eseguono gli stessi quattro passaggi: leggono il corpo grezzo, controllano l'età del timestamp, ricalcolano la firma e la confrontano in tempo costante.

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;

// È richiesto il corpo GREZZO: express.json() invalida la firma perché riscrive il 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();
});

Struttura del corpo

Gli eventi di generazione includono un involucro con ID di invio, tipo di evento e un oggetto data. Gli eventi del visualizzatore contengono direttamente i campi.

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

Le due strutture sono oggi volutamente diverse. Tienilo presente prima di scrivere il gestore: negli eventi del visualizzatore il tipo di evento si trova solo nell'intestazione X-PDFgine-Event, non nel corpo.

Invio e nuovi tentativi

Un webhook viene messo in coda, non inviato durante l'esecuzione, quindi un tuo endpoint lento non rallenta mai la produzione dei documenti.

  • Qualsiasi 2xx vale come consegna riuscita. Ogni altra risposta è un errore, anche un reindirizzamento.
  • Fino a 6 tentativi, con attesa esponenziale a partire da 15 secondi.
  • Una chiamata senza risposta entro 15 secondi viene considerata non riuscita e ritentata.
  • La destinazione deve usare https e risolversi in un indirizzo pubblico. localhost, nomi .local e intervalli privati vengono rifiutati alla registrazione dell'endpoint e di nuovo prima di ogni chiamata.

Poiché un nuovo tentativo ripete lo stesso ID X-PDFgine-Delivery, registra gli ID già gestiti e rispondi 2xx ai duplicati senza eseguire il lavoro due volte.

Errori →

Gli errori più comuni

  • La verifica di un corpo serializzato di nuovo non coincide mai. I framework che analizzano JSON riordinano le chiavi e cambiano gli spazi: conserva i byte grezzi prima di qualsiasi elaborazione.
  • Solo 2xx vale come consegna riuscita. Un 301 o 302 verso il gestore reale è un errore e causa un nuovo tentativo: usa l'indirizzo finale.
  • Controllare la firma ma non il timestamp rende una chiamata intercettata riproducibile per sempre. Rifiuta ciò che supera la tolleranza.
  • Un nuovo tentativo ripete lo stesso ID X-PDFgine-Delivery. Un gestore che riesegue il lavoro invia la stessa fattura due volte: registra gli ID gestiti.
  • Un endpoint registrato deve usare https ed essere risolvibile pubblicamente. localhost e intervalli privati vengono rifiutati alla registrazione: prova tramite un tunnel anziché combattere il rifiuto.