Ký & xác minh webhook
Citely ký mọi request bằng Standard Webhooks (HMAC-SHA256). Cách receiver xác minh.
Mọi GET/POST Citely gửi tới receiver đều được ký theo chuẩn mở
Standard Webhooks (HMAC-SHA256) bằng
CITELY_WEBHOOK_SECRET. Receiver verify chữ ký trên POST trước khi lưu bất cứ gì.
Headers trên mỗi delivery
webhook-id: msg_8b1f… (idempotency key — dedupe trên cái này)
webhook-timestamp: 1751270400 (UNIX seconds — reject nếu lệch > 5 phút)
webhook-signature: v1,K5l8… (danh sách "v1,<base64>" cách nhau bởi space)
content-type: application/json
user-agent: Citely/1.0 (+https://citely-seo.com)Thuật toán verify
id = header "webhook-id"
ts = header "webhook-timestamp" (UNIX seconds)
sigHeader = header "webhook-signature" (danh sách "v1,<base64>" cách nhau bởi space)
if !id || !ts || abs(now_seconds - ts) > 300: reject (401 CPP_REPLAY)
key = base64_decode( secret sau khi bỏ tiền tố "whsec_" )
signedContent = id + "." + ts + "." + rawBody
expected = base64( HMAC_SHA256(key, signedContent) )
# tách sigHeader theo space; mỗi token lấy phần SAU dấu phẩy; so sánh constant-time.
# BẤT KỲ token nào khớp → hợp lệ. (Danh sách cho phép xoay secret.)Ký trên RAW body (bytes nguyên văn). Nếu bạn JSON.parse rồi stringify lại để ký, whitespace/thứ tự khoá đổi → chữ ký sai. Đọc raw trước, verify, rồi mới parse.
Reference (Node/TypeScript, zero-dependency)
import { Buffer } from 'node:buffer';
import crypto from 'node:crypto';
function verify(secret: string, body: string, h: Record<string, string>): boolean {
const id = h['webhook-id'];
const ts = h['webhook-timestamp'];
const sig = h['webhook-signature'] ?? '';
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return false;
}
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const exp = crypto
.createHmac('sha256', key)
.update(`${id}.${ts}.${body}`)
.digest('base64');
const eb = Buffer.from(exp, 'base64');
return sig.split(' ').some((t) => {
const s = Buffer.from(t.includes(',') ? t.slice(t.indexOf(',') + 1) : t, 'base64');
return s.length === eb.length && crypto.timingSafeEqual(s, eb);
});
}Off-the-shelf tương đương:
npm i standardwebhooksimport { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.CITELY_WEBHOOK_SECRET!);
wh.verify(rawBody, headers); // throw nếu chữ ký/timestamp saiXoay secret
Header webhook-signature là danh sách chữ ký (cách nhau bởi space). Khi bạn xoay
secret trong Citely, một giai đoạn ngắn Citely gửi nhiều chữ ký — receiver coi là hợp lệ
nếu bất kỳ token nào khớp. Nhờ vậy bạn xoay secret không downtime.
Sai chữ ký → trả 401 CPP_SIG_INVALID. Lệch đồng hồ > 300s → 401 CPP_REPLAY. Xem Mã lỗi.