웹훅
전달되는 내용
PDFgine은 두 가지 상황을 콜백으로 알립니다. 대기열은 문서 생성 완료를 알리고, 뷰어는 문서를 공유받은 사람이 열람하거나 다운로드했을 때 알립니다.
document.render.succeeded— 대기열의 문서가 생성되어 다운로드할 수 있습니다document.render.failed— 대기열의 문서를 생성하지 못했으며 본문에 failureCode가 포함됩니다document.viewed— 공유 문서가 뷰어에서 열렸습니다document.downloaded— 공유 문서가 다운로드되었습니다
생성 이벤트는 생성 요청에 지정한 webhookUrl로 전송됩니다. 뷰어 이벤트는 POST /api/v1/engagement-webhooks로 한 번 등록한 엔드포인트로 전송되며 각 엔드포인트에서 받을 이벤트를 지정합니다.
모든 호출의 헤더
두 종류의 웹훅은 같은 헤더를 사용하므로 하나의 검증 루틴으로 모두 처리할 수 있습니다.
| 헤더 | 담긴 내용 |
|---|---|
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분 이상 차이 나는 타임스탬프의 호출은 거부하세요. 정상적인 시계 오차와 재시도는 허용하면서 가로챈 호출은 빠르게 무효가 되는 범위입니다.
두 서명은 상수 시간으로 비교하세요. 일반 문자열 비교는 마지막 문자가 틀린 경우보다 첫 문자가 틀린 경우에 더 빨리 끝나며 그 차이는 측정할 수 있습니다.
바로 실행할 수 있는 검증 코드
두 예제는 같은 네 가지 작업을 수행합니다. 원시 본문을 읽고, 타임스탬프의 경과 시간을 확인하고, 서명을 다시 계산한 다음 상수 시간으로 비교합니다.
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"
}
현재 두 구조는 의도적으로 다릅니다. 처리기를 작성하기 전에 뷰어 이벤트의 유형은 본문이 아닌 X-PDFgine-Event 헤더에만 있다는 점을 알아 두세요.
전달 및 재시도
웹훅은 즉시 전송되지 않고 대기열에 들어가므로 수신 엔드포인트가 느려도 문서 생성은 느려지지 않습니다.
- 모든 2xx는 전달 성공으로 처리됩니다. 리디렉션을 포함한 나머지 응답은 모두 실패입니다.
- 최대 6회 시도하며 15초부터 대기 시간을 지수적으로 늘립니다.
- 15초 안에 응답하지 않는 호출은 실패로 처리하고 재시도합니다.
- 대상은 https여야 하며 공개 주소로 확인되어야 합니다. localhost, .local 이름, 사설 주소 대역은 엔드포인트 등록 시와 매 호출 직전에 거부됩니다.
재시도에는 같은 X-PDFgine-Delivery ID가 사용됩니다. 처리한 ID를 기록하고 중복 호출에는 작업을 두 번 수행하지 않고 2xx로 응답하세요.
오류 →자주 발생하는 문제
- 다시 직렬화한 본문으로 검증하면 서명이 일치하지 않습니다. JSON을 파싱하는 프레임워크는 키 순서와 공백을 변경하므로 어떤 처리도 하기 전에 원시 바이트를 보관하세요.
- 2xx만 전달 성공입니다. 실제 처리기로 향하는 301 또는 302도 실패로 처리되어 재시도됩니다. 엔드포인트를 최종 주소로 지정하세요.
- 서명만 확인하고 타임스탬프를 확인하지 않으면 가로챈 호출을 영원히 재전송할 수 있습니다. 허용 범위보다 오래된 호출은 거부하세요.
- 재시도는 같은 X-PDFgine-Delivery ID를 사용합니다. 처리기가 다시 작업하면 같은 청구서를 두 번 보내므로 처리한 ID를 기록하세요.
- 등록된 엔드포인트는 https이며 공개 주소로 확인되어야 합니다. localhost와 사설 주소 대역은 등록 시 거부되므로 거부를 우회하려 하지 말고 터널로 테스트하세요.