Express 웹훅 서명 검증: raw body와 100kb 한도

Sume 웹훅 라우트에 type은 application/json, limit은 1 MiB보다 크게 설정한 express.raw를 붙이고, 원본 Buffer를 verifyWebhook에 넘기세요.

읽는 시간 5분Sume
전체 글

Express에서 웹훅 서명을 검증하려면 웹훅 라우트에만 express.raw({ type: "application/json", limit: "2mb" })를 붙이고, 이 미들웨어가 req.body에 넣어 주는 Buffer를 @sume-com/sdk의 verifyWebhook에 넘기세요. Sume에는 두 옵션이 모두 중요합니다. express.raw는 기본적으로 application/octet-stream만 파싱하며, 기본 limit인 100kb는 Sume가 웹훅 본문에 실행 영수증을 인라인으로 담는 상한인 1 MiB에 한참 못 미칩니다.

Express 관련 내용은 Express 자체 문서인 express.raw() 레퍼런스와 body-parser 페이지에서, Sume 관련 내용은 웹훅 검증, Run 웹훅 (영문), 웹훅 (영문)에서 가져왔습니다. 모두 2026-09-27에 확인했습니다. Express용 Sume 미들웨어는 없으며, 수신기는 일반 라우트입니다. 전달 디버깅은 Sume 웹훅이 도착하지 않나요?에서 다룹니다.

express.json()은 왜 서명을 깨뜨리나요?

Sume는 모든 전달을 <timestamp>.<raw_body>에 대한 HMAC-SHA256으로 서명합니다. 키 순서와 공백도 서명 대상의 일부이므로, 파싱했다가 다시 직렬화한 객체는 검증되지 않습니다. express.json()은 파싱된 객체를 req.body에 담아 라우트에 넘기므로 원래 바이트가 사라집니다. express.raw()는 바이트를 그대로 남깁니다. 본문을 담은 Buffer로 req.body를 채우기 때문입니다.

앱이 둘 다 쓴다면 순서가 중요합니다. Express는 파서를 여러 개 쌓으면 req.body가 다른 파서에서 올 수 있다고 경고하며, buffer 메서드를 호출하기 전에 req.body가 Buffer인지 확인하라고 권장합니다. 웹훅 라우트는 자체 express.raw와 함께, 앱 전체에 적용하는 express.json()보다 먼저 등록하세요.

라우트가 express.json() 뒤에 있어야만 한다면 대신 그 파서의 verify 옵션을 쓰세요. Express는 원본 요청 본문의 Buffer인 buf를 넘겨 이 함수를 호출하므로, 검증에 쓸 바이트를 보관할 수 있습니다. 이 파서의 limit 기본값도 100kb입니다.

Sume 웹훅에는 어떤 express.raw 옵션이 필요한가요?

기본값 두 가지가 발목을 잡습니다. Sume는 content-type: application/json을 보내는데 기본 type은 이와 맞지 않으며, 타입이 맞지 않으면 req.body는 undefined로 남습니다. limit을 넘는 본문은 413(entity.too.large)을 받습니다. Sume는 이를 거부된 시도로 보고 재시도하며, 같은 본문을 재시도할 때마다 같은 한도에 걸리다가 결국 시도 횟수를 모두 소진합니다. Express의 body-parser 페이지는 가능하면 기본 한도를 쓰라고 권장하고, 기본값보다 큰 값은 모두 매우 높은 값으로 봅니다. 페이로드가 클수록 메모리와 응답 시간이 더 들기 때문입니다. 그러니 이 라우트에서만, 그리고 1 MiB를 살짝 넘는 정도까지만 올리세요.

Express의 express.raw()와 body-parser 레퍼런스, Sume의 Run 웹훅 (영문) 기준, 2026-09-27 확인.
옵션Express 기본값Sume용 설정
typeapplication/octet-streamSume가 보내는 content-type인 application/json
limit100kb2mb처럼 1 MiB보다 크게. 1 MiB까지의 영수증은 인라인으로 도착함

완성된 Express 수신기는 어떤 모습인가요?

라우트는 검증하고, 기록하고, 응답한 뒤에야 작업합니다. verifyWebhook은 본문을 문자열, ArrayBuffer, 타입 배열로 받으므로 Buffer를 그대로 넘길 수 있고, Node의 req.headers 같은 일반 객체에서 헤더를 읽습니다. 이 함수는 async이고, 예외를 던지는 대신 false를 반환하며, 기본적으로 300초의 재전송 허용 시간을 적용합니다.

import express from "express";
import { verifyWebhook } from "@sume-com/sdk";

const app = express();
// Webhook route first, with its own raw parser.
app.post(
  "/hooks/sume",
  express.raw({ type: "application/json", limit: "2mb" }),
  async (req, res) => {
    if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);
    const ok = await verifyWebhook({
      body: req.body, // the raw Buffer, never a parsed object
      headers: req.headers,
      secret: process.env.SUME_COM_WEBHOOK_SIGNING_SECRET!,
    });
    if (!ok) return res.status(401).send("bad signature");
    const event = JSON.parse(req.body.toString("utf8"));
    const fresh = await recordOnce(event.request_id, event); // insert-or-ignore
    res.sendStatus(204); // answer well inside the 10 s attempt window
    if (fresh) handleEvent(event).catch(console.error); // work after answering
  },
);

app.use(express.json()); // the rest of the app

라우트는 언제, 무엇으로 응답해야 하나요?

어떤 2xx든 전달된 것으로 인정되고 시도마다 10초가 주어지므로, 라우트는 이벤트를 기록하고 204로 응답한 뒤에야 handleEvent를 호출합니다. Sume는 최대 10회 시도하며 매번 같은 request_id를 보내므로, insert-or-ignore를 가장 먼저 합니다. 나머지 규약은 Sume 영상 실행용 서명된 웹훅에서 다룹니다. 이 라우트와 관련된 점은 두 가지입니다.

  • 1 MiB를 넘는 영수증은 payload: null과 error.result_url을 담아 도착하므로, 본문은 올려 둔 한도 아래에 머뭅니다. 영수증은 그 URL에서 API 키로 가져오세요.
  • 서명 시크릿을 교체한 뒤 24시간 동안 x-sume-webhook-signature에는 sume-v1= 항목이 두 개 실립니다. @sume-com/sdk 0.2.0의 verifyWebhook은 둘 중 어느 쪽이든 받아들이지만, 헤더 전체를 비교하는 자체 구현 검증은 그 기간 동안 실패합니다.

Sume는 어떤 URL로 전달할 수 있나요?

공개 HTTPS URL입니다. 현재 코드에서는 :3000처럼 포트를 명시한 URL도 거부되므로, Express 앱을 기본 포트를 쓰는 공개 HTTPS 호스트 이름 뒤에 두세요. 다른 거부 사유는 Sume 웹훅 URL 규칙에, 로컬 개발 흐름은 localhost에서 Sume 웹훅 테스트하기에 정리되어 있습니다.

출처

관련 글

연동 카테고리의 다른 글

연동 글 전체 보기

작성자 Sume