추적

서명이 포함된 전송 웹훅

콘솔의 Webhooks 페이지에서 HTTPS 엔드포인트를 설정해요. 메시지가 최종 상태에 도달하면, 해당 엔드포인트로 JSON 이벤트가 포함된 POST 요청을 보내요.

이벤트

type시점
message.delivered메시지가 전송되었어요.
message.failed메시지 전송이 실패했어요. data.error에 코드가 있어요. 가격은 환불되었어요.
POST to your endpointJSON
{
  "id": "evt_8F3K2Q9TZ1MXD",
  "type": "message.delivered",
  "created_at": "2026-11-07T09:30:17.902Z",
  "data": {
    "id": "msg_8F3K2Q9TZ1MX",
    "object": "message",
    "batch_id": "bat_4M7Q1X0PK2RS",
    "to": "+14155550123",
    "country": "US",
    "text": "Your Northwind code is 482913. It expires in 10 minutes.",
    "segments": 1,
    "price": "0.00507",
    "currency": "USD",
    "status": "delivered",
    "error": null,
    "reference": "login-7781",
    "created_at": "2026-11-07T09:30:12.418Z",
    "sent_at": "2026-11-07T09:30:13.020Z",
    "done_at": "2026-11-07T09:30:17.902Z"
  }
}

헤더 및 서명

모든 호출에는 Standard Webhooks 형식으로 세 가지 헤더가 포함돼요:

헤더값
webhook-id이벤트 ID(evt_...). 동일한 이벤트의 재시도에도 같은 ID가 사용돼요.
webhook-timestamp이 시도가 서명된 Unix 시간(초 단위).
webhook-signaturev1, 그리고 base64 HMAC-SHA256 서명이 따라와요. 여러 서명이 공백으로 구분되어 나열될 수 있어요: 어떤 하나라도 일치하면 호출을 수락해요.

호출을 확인하려면: 콘솔에서 비밀키를 가져오세요 (whsec_로 시작함), whsec_ 뒤 부분을 base64url 디코딩해서 키를 얻고, webhook-id + "." + webhook-timestamp + "." + raw body의 HMAC-SHA256을 계산한 다음, base64 인코딩해서 상수 시간에 서명과 비교하세요. 현재 시간과 5분 이상 차이가 나는 타임스탬프는 거절하세요. 항상 원본(raw body)을 사용하세요: 파싱하고 다시 직렬화한 JSON 본문은 일치하지 않아요.

// Express: keep the raw body, check the signature, then answer 2xx quickly
import crypto from "node:crypto";
import express from "express";

const app = express();
const secret = process.env.BULKSMSAPI_WEBHOOK_SECRET; // whsec_...

app.post("/hooks/sms", express.raw({ type: "application/json" }), (req, res) => {
  const id = req.header("webhook-id");
  const ts = req.header("webhook-timestamp");
  const key = Buffer.from(secret.slice("whsec_".length), "base64url");
  const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${req.body}`).digest("base64");
  const sent = (req.header("webhook-signature") || "").split(" ").map((s) => s.replace(/^v1,/, ""));
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
  if (!fresh || !sent.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)))) {
    return res.status(401).end();
  }
  const event = JSON.parse(req.body);
  console.log(event.type, event.data.id, event.data.status);
  res.status(204).end();
});
app.listen(3000);

응답 및 재시도

  • 10초 이내에 2xx 상태로 응답하세요. 응답 후 큐에서 느린 작업을 수행하세요.
  • 리디렉션은 따르지 않아요. 설정을 최종 주소로 지정해주세요.
  • 타임아웃되거나 2xx 이외의 응답을 받은 호출은 30초, 2분, 10분, 30분, 1시간, 2시간, 4시간, 8시간, 8시간 간격으로 다시 시도해요. 총 10번 시도하며, 첫 시도부터 약 24시간이 걸려요.
  • 모든 시도는 웹훅 페이지에 HTTP 상태와 소요 시간과 함께 표시돼요.

중복 및 순서

이벤트는 두 번 도착할 수 있어요. 예를 들어 엔드포인트가 이벤트를 저장했지만 응답이 손실된 경우가 있죠. webhook-id를 저장하고 이미 처리한 ID는 건너뛰세요. 서로 다른 메시지의 이벤트는 어떤 순서로든 도착할 수 있으니, 도착 순서에 의존하지 말고 data.status와 data.done_at을 확인하세요.

엔드포인트 규칙

  • HTTPS만 사용해야 해요. 공개 호스트 이름을 사용하세요 (IP 리터럴, localhost, 사설 네트워크는 안 돼요).
  • 웹훅 페이지에서 시크릿을 언제든지 교체할 수 있어요. 교체 후에는 새로운 시크릿으로 서명된 호출이 사용돼요.
  • 엔드포인트를 제거하면 새로운 이벤트가 중지되지만, 이미 완료된 메시지는 폴링을 위해 상태를 유지해요.
서명이 포함된 전송 웹훅 | BulkSMSAPI 문서