제품 및 API 안내

웹훅

전달되는 내용

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-Signaturev1= 뒤에 소문자 16진수 서명이 이어집니다.
User-AgentPDFgine-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분 이상 차이 나는 타임스탬프의 호출은 거부하세요. 정상적인 시계 오차와 재시도는 허용하면서 가로챈 호출은 빠르게 무효가 되는 범위입니다.

두 서명은 상수 시간으로 비교하세요. 일반 문자열 비교는 마지막 문자가 틀린 경우보다 첫 문자가 틀린 경우에 더 빨리 끝나며 그 차이는 측정할 수 있습니다.

바로 실행할 수 있는 검증 코드

두 예제는 같은 네 가지 작업을 수행합니다. 원시 본문을 읽고, 타임스탬프의 경과 시간을 확인하고, 서명을 다시 계산한 다음 상수 시간으로 비교합니다.

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;

// 원시 본문이 필요합니다. 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);
});
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();
});

본문 구조

생성 이벤트는 전달 ID, 이벤트 유형, data 객체가 포함된 봉투 구조로 전송됩니다. 뷰어 이벤트는 필드를 직접 포함합니다.

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

현재 두 구조는 의도적으로 다릅니다. 처리기를 작성하기 전에 뷰어 이벤트의 유형은 본문이 아닌 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와 사설 주소 대역은 등록 시 거부되므로 거부를 우회하려 하지 말고 터널로 테스트하세요.