Webhooks
서명 검증
Standard Webhooks 헤더와 엔드포인트의 서명 시크릿으로, 요청이 Petify에서 온 것인지 검증하는 방법
왜 검증해야 하나요?
엔드포인트 URL을 아는 사람은 누구나 그 주소로 요청을 보낼 수 있습니다. 모든 요청의 서명을 검증하고, 실패한 요청은 거부하세요. Petify는 Standard Webhooks 방식으로 이벤트에 서명하므로, 이미 만들어진 라이브러리를 그대로 쓸 수 있습니다.
헤더
모든 요청에는 세 개의 헤더가 포함됩니다.
| 헤더 | 설명 |
|---|---|
webhook-id | 이벤트 ID. 본문의 id 필드와 같고, 재시도와 재전송에서도 바뀌지 않습니다. |
webhook-timestamp | 이번 전송 시도의 Unix 시간(초) |
webhook-signature | v1,<signature> 형식. 서명 시크릿을 키로 {webhook-id}.{webhook-timestamp}.{원본 본문}에 HMAC-SHA256을 적용한 뒤 Base64로 인코딩한 값 |
서명 시크릿은 whsec_... 형태입니다. Console에서 엔드포인트를 열고 시크릿 보기를 클릭해 복사하세요.
원본(raw) 본문으로 검증하세요. 받은 바이트 그대로 서명을 계산해야 합니다. 프레임워크가 JSON을 먼저 파싱하고 다시 직렬화하면 공백과 키 순서가 바뀌어 검증이 실패할 수 있습니다. 원본 본문을 읽어 검증한 뒤에 파싱하세요.
Node.js
standardwebhooks 패키지를 설치합니다.
npm install standardwebhooks expressimport express from "express";
import { Webhook } from "standardwebhooks";
const app = express();
const wh = new Webhook(process.env.PETIFY_WEBHOOK_SECRET); // whsec_...
// express.raw keeps the body as a Buffer, exactly as Petify sent it.
app.post(
"/webhooks/petify",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = wh.verify(req.body.toString("utf8"), {
"webhook-id": req.header("webhook-id"),
"webhook-timestamp": req.header("webhook-timestamp"),
"webhook-signature": req.header("webhook-signature"),
});
} catch {
return res.status(400).send("invalid signature");
}
// Deduplicate on the webhook-id header before you act on the event.
switch (event.type) {
case "verification_job.succeeded":
// event.data.jobId, event.data.petId, event.data.isVerified, event.data.score
break;
default:
// Ignore event types you do not handle.
break;
}
res.sendStatus(200);
},
);
app.listen(3000);Python
standardwebhooks 패키지를 설치합니다.
pip install standardwebhooks flaskimport os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
wh = Webhook(os.environ["PETIFY_WEBHOOK_SECRET"]) # whsec_...
@app.post("/webhooks/petify")
def petify_webhook():
# get_data() returns the raw bytes, exactly as Petify sent them.
payload = request.get_data()
headers = {
"webhook-id": request.headers.get("webhook-id", ""),
"webhook-timestamp": request.headers.get("webhook-timestamp", ""),
"webhook-signature": request.headers.get("webhook-signature", ""),
}
try:
event = wh.verify(payload, headers)
except WebhookVerificationError:
return "invalid signature", 400
# Deduplicate on the webhook-id header before you act on the event.
if event["type"] == "verification_job.succeeded":
data = event["data"] # jobId, petId, isVerified, score
# Ignore event types you do not handle.
return "", 200라이브러리는 webhook-timestamp도 확인해서, 서버 시계와 5분 넘게 차이 나는 요청을 거부합니다. 서버 시계를 NTP로 맞춰 두세요.
직접 검증하기
라이브러리를 쓸 수 없다면 서명을 직접 검증하세요. 순서는 다음과 같습니다.
webhook-timestamp가 현재 시각과 5분 넘게 차이 나면 요청을 거부합니다. 오래된 요청의 재생(replay)을 막습니다.- 서명 대상 문자열
{webhook-id}.{webhook-timestamp}.{원본 본문}을 만듭니다. - 시크릿에서
whsec_뒤의 부분을 Base64 디코딩합니다. 이 값이 HMAC 키입니다. - 서명 대상 문자열의 HMAC-SHA256을 계산하고 Base64로 인코딩합니다.
webhook-signature에 들어 있는 각 서명과 상수 시간 비교로 비교합니다. 하나라도 일치하면 요청을 받아들입니다.
import base64
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 5 * 60
def verify(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
# 1. Reject old (or far-future) timestamps.
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
# 2-4. Sign "{id}.{timestamp}.{body}" with the decoded secret.
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{msg_id}.{timestamp}.".encode() + raw_body
digest = hmac.new(key, signed_content, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
# 5. The header can hold several space-separated "v1,<signature>" values.
for part in headers["webhook-signature"].split(" "):
version, _, signature = part.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False시크릿 교체
Console에서 엔드포인트를 열고 시크릿 교체를 클릭합니다. Petify가 새 시크릿을 만들고 바로 그 시크릿으로 서명합니다.
- 교체 후 24시간 동안은 이전 시크릿도 계속 서명에 쓰입니다. 이 기간에는
webhook-signature헤더에 새 서명과 이전 서명, 두 개의 서명이 공백으로 구분되어 들어 있습니다. - 하나라도 검증되면 요청을 받아들이세요. 위의 라이브러리와 직접 검증 예제는 이미 이렇게 동작합니다.
- 24시간 안에 새 시크릿을 서버에 배포하세요. 기간이 지난 뒤의 요청에는 새 서명만 들어 있습니다.
시크릿을 교체해도 전송이 끊기지 않습니다. 새 시크릿을 먼저 배포한 뒤에 이전 시크릿 사용을 중단하세요.
문제 해결
| 증상 | 가능한 원인 |
|---|---|
| 모든 요청의 검증이 실패함 | 다른 엔드포인트의 시크릿처럼 잘못된 시크릿을 쓰고 있거나, 시크릿을 교체한 지 24시간이 지났는데 서버가 이전 시크릿을 쓰고 있습니다. |
| 일부 요청만 검증이 실패함 | 프레임워크가 본문을 읽기 전에 바꾸고 있습니다(JSON 파싱, 압축 해제, 공백 제거 등). 원본 바이트로 검증하세요. |
| 타임스탬프가 거부됨 | 서버 시계가 5분 넘게 어긋나 있습니다. |