서명 검증
엔드포인트 URL을 아는 사람은 누구나 여기에 요청을 보낼 수 있습니다. 페이로드를 신뢰하기 전에 항상 서명을 검증하세요.
서명 방식
섹션 제목: “서명 방식”각 전달에는 다음이 포함됩니다.
webhook-id: 0192e1c4-0000-7000-8000-0000000000ccwebhook-timestamp: 1761228131webhook-signature: v1,K5oZfzN0mYI+Hx3Yb0v8j3H5kq2tqZ2dA8s0r6sF9Vw=서명은 "{webhook-id}.{webhook-timestamp}.{raw body}"의 base64 HMAC-SHA256이며, 시크릿의 바이트를 키로 사용합니다. signing_secret에서 whsec_ 접두사를 떼어 내고 나머지를 base64 디코딩하세요. 재전송 공격을 막으려면 타임스탬프가 현재 시각과 5분 넘게 차이 나는 요청은 거부하세요.
SpacePost SDK는 WebCrypto로 검증하고 파싱된 이벤트를 반환합니다. 서명이나 타임스탬프가 유효하지 않으면 예외를 던집니다.
import { SpacePost } from "@spacepost/node";
const spacepost = new SpacePost(process.env.SPACEPOST_API_KEY);
/** Works as a Bun, Deno, Cloudflare Workers or Next.js route handler. */export async function POST(request: Request): Promise<Response> { const payload = await request.text(); // the RAW body: do not parse it first
try { const event = await spacepost.webhooks.verify({ payload, headers: request.headers, secret: process.env.SPACEPOST_WEBHOOK_SECRET!, });
switch (event.type) { case "email.bounced": case "email.complained": console.warn("Stop mailing", event.data.to); break; case "email.delivered": console.log("Delivered", event.data.email_id); break; } return new Response("ok"); } catch { return new Response("invalid signature", { status: 400 }); }}헤더는 Headers 객체이거나 일반 객체(예: Express의 req.headers)일 수 있습니다. Express에서는 웹훅 라우트에 express.raw({ type: "application/json" })를 적용해 req.body가 Buffer가 되게 하세요.
standardwebhooks 패키지를 사용하는 경우:
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.SPACEPOST_WEBHOOK_SECRET!); // "whsec_..." is acceptedconst event = wh.verify(rawBody, { "webhook-id": req.headers["webhook-id"] as string, "webhook-timestamp": req.headers["webhook-timestamp"] as string, "webhook-signature": req.headers["webhook-signature"] as string,});svix(또는 resend 패키지의 검증기)를 사용하는 경우: SpacePost가 함께 보내는 svix-* 헤더를 사용하세요.
import { Webhook } from "svix";
const wh = new Webhook(process.env.SPACEPOST_WEBHOOK_SECRET!);const event = wh.verify(rawBody, { "svix-id": req.headers["svix-id"] as string, "svix-timestamp": req.headers["svix-timestamp"] as string, "svix-signature": req.headers["svix-signature"] as string,});pip install standardwebhooksimport osfrom flask import Flask, requestfrom standardwebhooks.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)wh = Webhook(os.environ["SPACEPOST_WEBHOOK_SECRET"]) # "whsec_..." is accepted
@app.post("/webhooks/spacepost")def spacepost_webhook(): try: event = wh.verify(request.get_data(as_text=True), dict(request.headers)) except WebhookVerificationError: return "invalid signature", 400 print(event["type"], event["data"]["email_id"]) return "", 200라이브러리 없이 검증하는 경우:
import base64, hashlib, hmac, time
def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool: msg_id = headers["webhook-id"] ts = headers["webhook-timestamp"] if abs(time.time() - int(ts)) > tolerance: return False key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{ts}.".encode() + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() return any(hmac.compare_digest(s.split(",", 1)[1], expected) for s in headers["webhook-signature"].split() if s.startswith("v1,"))package main
import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "io" "math" "net/http" "os" "strconv" "strings" "time")
func verify(secret string, h http.Header, body []byte) bool { id := h.Get("webhook-id") ts := h.Get("webhook-timestamp") n, err := strconv.ParseInt(ts, 10, 64) if err != nil || math.Abs(float64(time.Now().Unix()-n)) > 300 { return false } key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) if err != nil { return false } mac := hmac.New(sha256.New, key) mac.Write([]byte(id + "." + ts + ".")) mac.Write(body) expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) for _, s := range strings.Fields(h.Get("webhook-signature")) { if v, ok := strings.CutPrefix(s, "v1,"); ok && hmac.Equal([]byte(v), []byte(expected)) { return true } } return false}
func main() { http.HandleFunc("/webhooks/spacepost", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) if !verify(os.Getenv("SPACEPOST_WEBHOOK_SECRET"), r.Header, body) { http.Error(w, "invalid signature", http.StatusBadRequest) return } fmt.Println("event:", string(body)) w.WriteHeader(http.StatusOK) }) http.ListenAndServe(":8080", nil)}또는 공식 라이브러리를 사용하세요. go get github.com/standard-webhooks/standard-webhooks/libraries/go를 실행한 뒤 wh, _ := standardwebhooks.NewWebhook(secret); err := wh.Verify(body, r.Header)를 호출합니다.
<?phpfunction verify_spacepost_webhook(string $secret, array $headers, string $body, int $tolerance = 300): bool { $headers = array_change_key_case($headers, CASE_LOWER); $id = $headers['webhook-id'] ?? ''; $ts = $headers['webhook-timestamp'] ?? ''; if (!ctype_digit($ts) || abs(time() - (int) $ts) > $tolerance) { return false; } $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); $expected = base64_encode(hash_hmac('sha256', "$id.$ts.$body", $key, true)); foreach (explode(' ', $headers['webhook-signature'] ?? '') as $sig) { if (str_starts_with($sig, 'v1,') && hash_equals($expected, substr($sig, 3))) { return true; } } return false;}
$body = file_get_contents('php://input');if (!verify_spacepost_webhook(getenv('SPACEPOST_WEBHOOK_SECRET'), getallheaders(), $body)) { http_response_code(400); exit('invalid signature');}$event = json_decode($body, true);error_log($event['type'] . ' ' . $event['data']['email_id']);http_response_code(200);Resend SDK의 검증기 사용하기
섹션 제목: “Resend SDK의 검증기 사용하기”공식 resend 패키지의 웹훅 검증은 같은 Standard Webhooks 헤더를 받으므로 SpacePost의 signing_secret으로도 동작합니다.
시크릿 교체하기
섹션 제목: “시크릿 교체하기”signing_secret은 웹훅마다 고정입니다. 교체하려면 같은 엔드포인트로 새 웹훅을 만들고, 핸들러가 새 시크릿을 받아들이도록 전환한 뒤(전환하는 동안에는 두 시크릿을 모두 시도하세요) 이전 웹훅을 삭제하세요.
로컬에서 테스트하기
섹션 제목: “로컬에서 테스트하기”터널 URL을 엔드포인트로 등록한 상태에서 delivered@test.spacepost.dev로 이메일을 보내세요. 테스트를 참고하세요.