Webhook
配信される内容
PDFgineは、2種類のイベントをコールバックで通知します。キューはドキュメントの生成完了を通知し、ビューアーは共有先の人がドキュメントを開いたり、ダウンロードしたりしたことを通知します。
document.render.succeeded— キューに入れたドキュメントが生成され、ダウンロードできる状態ですdocument.render.failed— キューに入れたドキュメントを生成できませんでした。本文にfailureCodeが含まれますdocument.viewed— 共有ドキュメントがビューアーで開かれましたdocument.downloaded— 共有ドキュメントがダウンロードされました
生成イベントは、生成リクエストで指定したwebhookUrlに送信されます。ビューアーのイベントは、POST /api/v1/engagement-webhooksで一度登録したエンドポイントに送信されます。各エンドポイントで、受け取るイベントを指定します。
すべての呼び出しのヘッダー
両方のWebhookで同じヘッダーを使用するため、1つの検証処理ですべてに対応できます。
| ヘッダー | 内容 |
|---|---|
X-PDFgine-Event | イベントの種類。例:document.render.succeeded。 |
X-PDFgine-Delivery | 配信IDです。再試行時も同じIDを使用するため、ハンドラーの冪等性を確保するために利用してください。 |
X-PDFgine-Timestamp | 呼び出しが署名された時刻を、UTCのUnix秒で表したものです。 |
X-PDFgine-Signature | v1=の後に、小文字の16進数による署名が続きます。 |
User-Agent | PDFgine-Webhooks/1.0 |
署名の生成方法
署名はタイムスタンプと本文を一緒に対象とするため、取得した呼び出しを後から新しいタイムスタンプに変えて再送することはできません。
X-PDFgine-Signature: v1=<hex>
hex = HMAC-SHA256(
key = <the secret you registered>,
message = <X-PDFgine-Timestamp> + "." + <raw request body>
) // lower-case hexadecimal
受信した生のバイト列に署名してください。再シリアライズしたオブジェクトは使わないでください。JSONパーサーによってキーの順序や空白が変わると、署名は一致しなくなります。これは、検証失敗の最も一般的な原因です。
タイムスタンプの許容範囲
許容範囲を決めるのはPDFgineではなく、ご自身です。ご自身の時計から5分を超えて離れたタイムスタンプの呼び出しは拒否してください。通常の時計のずれや再試行を許容しつつ、取得された呼び出しが短時間で利用不能になる範囲です。
2つの署名は、定時間で比較してください。通常の文字列比較は、最初の文字が異なる場合のほうが最後の文字が異なる場合より早く終了し、その差は測定できます。
すぐに使える検証コード
両方の例で、同じ4つの処理を行います。生の本文を読み取り、タイムスタンプの経過時間を確認し、署名を再計算して、定時間で比較します。
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.PDFGINE_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
// 生の本文が必要です。express.json()は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();
});
本文の形式
生成イベントは、配信ID、イベントの種類、dataオブジェクトを含むエンベロープで送信されます。ビューアーのイベントは、フィールドを直接含みます。
{
"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"
}
現在、この2つの形式は意図的に異なります。ハンドラーを作成する前に、ビューアーのイベントの種類は本文ではなく、X-PDFgine-Eventヘッダーにのみ含まれることを確認してください。
配信と再試行
Webhookは即時送信ではなくキューに入るため、受信エンドポイントの応答が遅くても、ドキュメントの生成は遅くなりません。
- 2xxはすべて配信成功と見なします。それ以外は、リダイレクトを含めて失敗です。
- 最大6回試行し、15秒から指数的に待機時間を延ばします。
- 15秒以内に応答しない呼び出しは失敗と見なされ、再試行されます。
- 送信先はhttpsで、公開アドレスに名前解決される必要があります。localhost、.local名、プライベートアドレス範囲は、登録時および各呼び出しの直前に拒否されます。
再試行では同じX-PDFgine-Delivery IDを使用するため、処理済みのIDを記録してください。重複した呼び出しには、処理を二重に実行せずに2xxを返します。
エラー →よくある問題
- 再シリアライズした本文で検証しても、署名は一致しません。JSONを解析するフレームワークはキーの順序や空白を変えるため、処理前の生のバイト列を保持してください。
- 配信成功と見なすのは2xxのみです。実際のハンドラーへの301や302も失敗となり、再試行されます。エンドポイントは最終アドレスを直接指すようにしてください。
- 署名だけを確認し、タイムスタンプを確認しないと、取得した呼び出しを無期限に再送できてしまいます。許容範囲より古いものは拒否してください。
- 再試行は同じX-PDFgine-Delivery IDを使用します。処理をもう一度実行するハンドラーでは、同じ請求書が二重送信されるため、処理済みIDを記録してください。
- 登録するエンドポイントはhttpsで、公開アドレスに名前解決される必要があります。localhostとプライベートアドレス範囲は登録時に拒否されるため、拒否を回避しようとせず、トンネル経由でテストしてください。