ตรวจลายเซ็น webhook (Ed25519)
ทุก webhook ที่เราส่งมีลายเซ็น Ed25519 · ห้ามแตะข้อมูลใน body ก่อน verify ผ่าน
ทำไม Ed25519 ไม่ใช่ HMAC
| HMAC (ที่เจ้าอื่นใช้) | Ed25519 (ของเรา) | |
|---|---|---|
| ใครถือความลับ | ทั้งสองฝั่ง ถือ secret เดียวกัน | เราถือ private key · คุณถือ public key |
| ฐานข้อมูลฝั่งเราหลุด | ผู้โจมตีปลอม webhook ถึงคุณได้ทันที | ปลอมไม่ได้ — ไม่มี private key ในมือ |
| ฐานข้อมูลฝั่งคุณหลุด | ผู้โจมตีปลอม webhook ถึงคุณได้เช่นกัน | public key เป็นของสาธารณะอยู่แล้ว ไม่มีอะไรเสียหาย |
| เก็บ key ยังไง | ต้องอยู่ใน secret manager หมุนตามรอบ | เก็บใน ENV / commit ลงโค้ดได้ (แต่ต้อง subscribe webhook.key.rotating) |
| ดึง key จากที่ไหน | ต้อง auth | GET /v1/webhooks/signing-keys ไม่ต้อง auth |
สิ่งที่คุณต้องทำเพิ่มจากที่เคยทำกับ HMAC
- เลิกใช้
crypto.createHmac— มันจะไม่มีวัน verify ผ่าน และไม่มีค่า shared secret ในระบบนี้ให้ใช้ - Node ใช้
crypto.verify(null, ...)· PHP ใช้sodium_crypto_sign_verify_detached() - ดึง public key มา cache เอง แล้ว handle กรณีเจอ
kidที่ไม่รู้จัก
Header ที่เราส่ง
POST /webhooks/pg HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: PG-Webhooks/1.0
X-PG-Signature: v1,kid=whk_2026a,t=1755749553,s=3rPYq...VQ
X-PG-Event-Id: evt_01K3M8QW7Z4N2VYB6H0RSTA9CD
X-PG-Event-Type: deposit.settled
X-PG-Delivery-Id: whd_01K3M8QX5DHN2VRT0BKZQ7W4YM
X-PG-Endpoint-Id: whep_01K2A9F3TNXR7VB2HD0MKQ5CJW
X-PG-Attempt: 3
X-PG-Api-Version: 2026-08-01
X-PG-Livemode: true| header | ความหมาย |
|---|---|
X-PG-Signature | รูปแบบคงที่ v1,kid=<kid>,t=<unix_seconds>,s=<base64url ไม่มี padding> · 🔴 parse แบบ key=value ห้าม parse ด้วยตำแหน่ง — เราอาจเพิ่ม field ในอนาคต |
kid (ใน signature) | id ของกุญแจที่ใช้เซ็นใบนี้ — จำเป็นตอนหมุนกุญแจ |
t (ใน signature) | unix วินาที ที่สร้างลายเซ็น ⇒ เปลี่ยนทุก attempt และทุกครั้งที่กดส่งซ้ำ |
s (ใน signature) | ลายเซ็น Ed25519 64 ไบต์ เข้ารหัส base64url ไม่มี = |
X-PG-Event-Id | event.id — ใช้ประกอบ canonical string |
X-PG-Event-Type | ชื่อ event — ใช้ route/log ก่อน parse |
X-PG-Delivery-Id | id ของการส่งครั้งนี้ · log ไว้ทุกครั้ง |
X-PG-Endpoint-Id | endpoint ปลายทางของการส่งครั้งนี้ |
X-PG-Attempt | รอบที่เท่าไร |
X-PG-Resend | ไม่มี header นี้ใน delivery ปกติ · เป็น manual เมื่อกดส่งซ้ำ · ไม่ใช่ส่วนหนึ่งของ event |
X-PG-Api-Version | version ที่ render ใบนี้ |
X-PG-Livemode | true / false |
prefix X-PG- | คงที่เสมอ ไม่เปลี่ยนตามแบรนด์ผู้ให้บริการ |
แยก event ออกจาก delivery ให้ชัด
ข้อมูล business event อยู่ใน body ส่วน delivery_id, endpoint_id, attempt และ resend อยู่ใน header เท่านั้น เพราะค่าเหล่านี้เปลี่ยนได้ในแต่ละ attempt
X-PG-Event-Id ต้องใช้ประกอบ canonical string ก่อน verify และ หลัง verify ต้องเทียบว่าตรงกับ event.id ใน body ส่วน delivery headers ไม่ได้อยู่ใน canonical signature จึงใช้ได้เพื่อ log/trace หลัง event verify ผ่านเท่านั้น ห้ามใช้ตัดสินเรื่องเงิน สิทธิ์ หรือ business state
Canonical string ที่เราเซ็น
เซ็นบน ไบต์ ไม่ใช่บนสตริงที่ parse แล้ว:
signed_bytes = utf8("v1.") ‖ utf8(t) ‖ utf8(".") ‖ utf8(event_id) ‖ utf8(".") ‖ RAW_BODY_BYTESตัวอย่างเชิงแนวคิด (บรรทัดเดียว ไม่มี newline คั่น):
v1.1755749553.evt_01K3M8QW7Z4N2VYB6H0RSTA9CD.{"id":"evt_01K3M8QW7Z4N2VYB6H0RSTA9CD","object":"event",...}ทำไมออกแบบแบบนี้
- ไม่มีขั้น hash แยกต่างหาก ⇒ merchant พลาดน้อยลง
- ผูก
tเข้าไปด้วย ⇒ แก้ timestamp = ลายเซ็นพัง ⇒ หน้าต่างกัน replay บังคับใช้ได้จริง - ผูก
event_id⇒ สลับ body ระหว่าง delivery สองใบไม่ได้ - ใช้ raw bytes ⇒ ปัญหาเรื่องลำดับ key, unicode escape, whitespace หายไปทั้งหมด
ห้าม re-serialize เด็ดขาด
เราไม่รับประกันลำดับ key ใน JSON แต่ raw body ของ event เดียวกัน เหมือนเดิมทุกไบต์ ระหว่าง retry และ manual resend; เฉพาะ delivery headers, t และลายเซ็นเท่านั้นที่เปลี่ยน
JSON.stringify(req.body) / json_encode($data) = ไบต์คนละชุดกับที่เราเซ็น ⇒ verify fail แบบสุ่ม โดยเฉพาะเมื่อ payload มีอักษรไทยหรือ emoji · นี่คือบั๊กที่ debug ยากที่สุดในลิสต์นี้เพราะมันผ่านตอนเทสต์ด้วย payload ภาษาอังกฤษ
กันการ replay — ต้องทำครบ 3 ชั้น
| ชั้น | ทำอะไร | ไม่ผ่านตอบอะไร |
|---|---|---|
| 1 | verify ลายเซ็นบน raw bytes | 401 |
| 2 | abs(now − t) > 300 วินาที | 400 |
| 3 | dedupe ด้วย event.id ใน transactional inbox — เก็บอย่างน้อย 31 วัน | 🔴 200 |
ชั้นที่ 3 ต้องตอบ 200 ไม่ใช่ 409
ตอบ non-2xx เมื่อเจอ event ซ้ำ = เรานับว่า fail = เรา retry ต่อ = คุณโดนถล่มด้วย event เดิม 11 รอบ
event ซ้ำเป็นเรื่องปกติมาก (retry ที่ response หายกลางทาง, การกดส่งซ้ำ) — มันไม่ใช่ error
CREATE TABLE pg_webhook_inbox (
event_id text PRIMARY KEY,
event_type text NOT NULL,
raw_body bytea NOT NULL,
event jsonb NOT NULL,
delivery_headers jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
CREATE INDEX ON pg_webhook_inbox (received_at);
CREATE TABLE pg_outbox (
id bigserial PRIMARY KEY,
topic text NOT NULL,
dedupe_key text NOT NULL UNIQUE,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
CREATE INDEX ON pg_outbox (id) WHERE published_at IS NULL;เก็บ inbox อย่างน้อย 31 วัน เพราะ event ส่งซ้ำด้วยมือได้ถึง 30 วันนับจาก created_at การลบที่ 7 วันจะทำให้ manual resend ใบเดิมถูกประมวลผลซ้ำ
Node.js — ✅ ถูก
import express from 'express';
import crypto from 'node:crypto';
const app = express();
// 🔑 ต้องเป็น raw body — ห้ามใช้ express.json() บน path นี้
app.post('/webhooks/pg', express.raw({ type: '*/*', limit: '512kb' }), async (req, res) => {
const raw = req.body; // Buffer ดิบ
const header = req.get('X-PG-Signature') || '';
// 1) parse header แบบ key=value ห้าม parse ด้วยตำแหน่ง
const parts = Object.fromEntries(
header.split(',').map(s => s.trim()).filter(s => s.includes('='))
.map(s => { const i = s.indexOf('='); return [s.slice(0, i), s.slice(i + 1)]; }));
const { kid, t, s: sig } = parts;
if (!header.startsWith('v1,') || !kid || !t || !sig) return res.status(400).end();
// 2) replay window ±5 นาที (เช็คก่อน verify เพื่อลด CPU)
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (!Number.isFinite(skew) || skew > 300) return res.status(400).end();
// 3) event id จาก header ใช้ประกอบ canonical เท่านั้น (ยัง verify ไม่ผ่าน = ยังเชื่อไม่ได้)
const eventIdHeader = req.get('X-PG-Event-Id') || '';
if (!/^evt_[A-Za-z0-9_]{10,80}$/.test(eventIdHeader)) return res.status(400).end();
// 4) canonical bytes: "v1." + t + "." + event_id + "." + RAW BODY
const signed = Buffer.concat([Buffer.from(`v1.${t}.${eventIdHeader}.`, 'utf8'), raw]);
// 5) verify Ed25519
const pub = await getPublicKey(kid);
if (!pub) return res.status(400).end();
if (!crypto.verify(null, signed, pub, Buffer.from(sig, 'base64url'))) return res.status(401).end();
// 6) parse ได้ "หลัง" verify เท่านั้น
const event = JSON.parse(raw.toString('utf8'));
if (event.id !== eventIdHeader) return res.status(400).end();
if (event.livemode !== (process.env.NODE_ENV === 'production')) return res.status(400).end();
// 7) inbox + outbox ต้อง commit พร้อมกัน ห้าม markSeen แล้ว enqueue คนละขั้น
// ถ้า DB ล่ม transaction จะ rollback และเราตอบ non-2xx เพื่อให้ต้นทาง retry
const fresh = await db.tx(async (t) => {
const deliveryHeaders = {
delivery_id: req.get('X-PG-Delivery-Id'),
endpoint_id: req.get('X-PG-Endpoint-Id'),
attempt: Number(req.get('X-PG-Attempt')),
resend: req.get('X-PG-Resend'),
};
const inserted = await t.query(`
INSERT INTO pg_webhook_inbox
(event_id, event_type, raw_body, event, delivery_headers)
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id
`, [event.id, event.type, raw, JSON.stringify(event), JSON.stringify(deliveryHeaders)]);
if (inserted.rowCount === 0) return false;
await t.query(`
INSERT INTO pg_outbox (topic, dedupe_key, payload)
VALUES ('process_pg_webhook', $1, $2::jsonb)
ON CONFLICT (dedupe_key) DO NOTHING
`, [`pg-webhook:${event.id}`, JSON.stringify({ event_id: event.id })]);
return true;
});
if (!fresh) return res.status(200).end(); // ซ้ำ = 200 ไม่ใช่ 409
return res.status(200).end();
});
// ---- key cache ----
const keyCache = new Map(); let lastFetch = 0;
async function getPublicKey(kid) {
const hit = keyCache.get(kid);
if (hit && Date.now() - hit.at < 24 * 3600_000) return hit.key;
if (Date.now() - lastFetch < 60_000) return hit?.key ?? null; // throttle 1/นาที
lastFetch = Date.now();
const { keys } = await (await fetch('https://api.example-wl.com/v1/webhooks/signing-keys')).json();
for (const k of keys) {
if (k.alg !== 'Ed25519') continue;
keyCache.set(k.kid, {
key: crypto.createPublicKey({ key: Buffer.from(k.public_key, 'base64'), format: 'der', type: 'spki' }),
at: Date.now() });
}
return keyCache.get(kid)?.key ?? null;
}outbox relay ต้องอ่านแถวที่
published_at IS NULLด้วยFOR UPDATE SKIP LOCKED, publish โดยใช้dedupe_keyแล้วค่อยตั้งpublished_atถ้า relay ตายหลัง publish อาจส่งซ้ำได้ แต่ event ไม่หาย และ worker ยัง idempotent ด้วยevent_idถ้าจะใช้
raw_public_key(32 ไบต์) กับ Node: ห่อเป็น SPKI เองด้วย prefix คงที่Buffer.concat([Buffer.from('302a300506032b6570032100','hex'), raw32])แล้วส่งเข้าcreatePublicKeyแบบเดียวกัน
Node.js — ❌ ผิด
ทุกคอมเมนต์ในโค้ดนี้คือบั๊กที่เจอจริงในโปรดักชัน
app.use(express.json()); // ❌ #1 กิน raw body ทิ้ง
app.post('/webhooks/pg', async (req, res) => {
const sigHeader = req.headers['x-pg-signature'];
const [, kidPart, tPart, sPart] = sigHeader.split(','); // ❌ #2 parse ด้วยตำแหน่ง
const t = tPart.split('=')[1], sig = sPart.split('=')[1];
const body = JSON.stringify(req.body); // ❌ #3 re-serialize! key order/unicode ต่างจากไบต์จริง
const expected = crypto.createHmac('sha256', process.env.PG_SECRET) // ❌ #4 HMAC ทั้งที่เป็น Ed25519
.update(t + '.' + body).digest('hex');
if (expected == sig) { // ❌ #5 == (timing attack) + เทียบ hex กับ base64url
// ❌ #6 ไม่เช็ค timestamp → replay ได้ตลอดกาล
// ❌ #7 ไม่ dedupe ด้วย event.id → retry รอบที่ 3 เครดิตซ้ำ
await creditUser(req.body.data.object.metadata.user_id, // ❌ #12 optional metadata
req.body.data.object.credited_amount_minor); // ❌ #8 ผิดตัวเลข
await sendEmail(); await syncToERP(); await rebuildReport(); // ❌ #9 เกิน 15 วิ = timeout = retry
res.json({ ok: true }); // ❌ #10 body ใหญ่โดยไม่จำเป็น
} else {
res.status(500).send('bad sig'); // ❌ #11 500 = เรา retry 11 รอบ
}
});| # | ผลที่เกิดขึ้นจริง |
|---|---|
| 1, 3 | verify พังแบบสุ่มเมื่อ payload มีอักษรไทย/emoji — ผ่าน QA แล้วไปพังบน production |
| 2 | เพิ่ม field ใน header เมื่อไร โค้ดพังเมื่อนั้น |
| 4 | ไม่มีวัน verify ผ่าน — และ PG_SECRET ไม่มีอยู่จริงในระบบนี้ |
| 5 | เทียบสตริงธรรมดาเปิดช่อง timing attack · และคนละ encoding ทำให้ผลลัพธ์ผิดตั้งแต่ต้น ⇒ ใช้ crypto.verify ซึ่ง constant-time ในตัว |
| 6, 7 | เครดิตลูกค้าซ้ำทุกครั้งที่เรา retry — สูงสุด 11 เท่า |
| 8 | 🔴 เครดิตผู้ใช้ปลายทางขาดเท่ากับค่าธรรมเนียมทุกออร์เดอร์ — ต้องใช้ amount_received_minor · credited_amount_minor ใช้กับกระเป๋า merchant เท่านั้น |
| 12 | metadata.user_id อาจไม่มี; ต้องใช้ correlation ที่ persist จาก create request+response |
| 9 | เกิน 15 วิ → นับเป็น fail → retry → ทำงานซ้ำ → ช้าลงอีก → วนจนตาย |
| 11 | ตอบ 500 ทุกครั้งที่ลายเซ็นผิด = 11 รอบต่อ event × ทุก event = ถล่มตัวเอง |
PHP — ✅ ถูก
ใช้ libsodium ที่มากับ PHP core ตั้งแต่ 7.2 ไม่ต้องลง extension เพิ่ม
<?php
declare(strict_types=1);
// 🔑 raw body — อ่านให้เร็วที่สุด (บาง framework อ่าน php://input ไปแล้ว ให้ใช้ $request->getContent())
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '' || strlen($raw) > 524288) { http_response_code(400); exit; }
$header = $_SERVER['HTTP_X_PG_SIGNATURE'] ?? '';
if (strncmp($header, 'v1,', 3) !== 0) { http_response_code(400); exit; }
$parts = [];
foreach (explode(',', $header) as $seg) {
$seg = trim($seg); $pos = strpos($seg, '=');
if ($pos !== false) { $parts[substr($seg, 0, $pos)] = substr($seg, $pos + 1); }
}
$kid = $parts['kid'] ?? null; $t = $parts['t'] ?? null; $sigB64 = $parts['s'] ?? null;
if (!$kid || !$t || !$sigB64 || !ctype_digit($t)) { http_response_code(400); exit; }
if (abs(time() - (int)$t) > 300) { http_response_code(400); exit; }
$eventId = $_SERVER['HTTP_X_PG_EVENT_ID'] ?? '';
if (!preg_match('/^evt_[A-Za-z0-9_]{10,80}$/', $eventId)) { http_response_code(400); exit; }
$signed = 'v1.' . $t . '.' . $eventId . '.' . $raw;
$sig = sodium_base642bin($sigB64, SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
if (strlen($sig) !== 64) { http_response_code(400); exit; }
$pk = pg_public_key_for_kid($kid); // 🔴 binary 32 ไบต์ จาก raw_public_key
if ($pk === null || strlen($pk) !== 32) { http_response_code(400); exit; }
if (!sodium_crypto_sign_verify_detached($sig, $signed, $pk)) { http_response_code(401); exit; }
$event = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
if (($event['id'] ?? null) !== $eventId) { http_response_code(400); exit; }
if (($event['livemode'] ?? null) !== (getenv('APP_ENV') === 'production')) { http_response_code(400); exit; }
$pdo->beginTransaction();
$stmt = $pdo->prepare(
'INSERT INTO pg_webhook_inbox
(event_id, event_type, raw_body, event, delivery_headers)
VALUES (?, ?, ?, ?::jsonb, ?::jsonb)
ON CONFLICT (event_id) DO NOTHING RETURNING event_id'
);
$deliveryHeaders = json_encode([
'delivery_id' => $_SERVER['HTTP_X_PG_DELIVERY_ID'] ?? null,
'endpoint_id' => $_SERVER['HTTP_X_PG_ENDPOINT_ID'] ?? null,
'attempt' => (int)($_SERVER['HTTP_X_PG_ATTEMPT'] ?? 0),
'resend' => $_SERVER['HTTP_X_PG_RESEND'] ?? null,
], JSON_THROW_ON_ERROR);
$stmt->execute([$event['id'], $event['type'], $raw, $raw, $deliveryHeaders]);
if ($stmt->rowCount() === 0) {
$pdo->commit();
http_response_code(200); exit; // เคยเห็นแล้ว
}
$outbox = $pdo->prepare(
"INSERT INTO pg_outbox (topic, dedupe_key, payload)
VALUES ('process_pg_webhook', ?, ?::jsonb) ON CONFLICT (dedupe_key) DO NOTHING"
);
$outbox->execute(['pg-webhook:' . $event['id'], json_encode(['event_id' => $event['id']], JSON_THROW_ON_ERROR)]);
$pdo->commit(); // inbox + outbox durable พร้อมกัน
http_response_code(200);PHP gotcha ที่เสียเวลาบ่อยที่สุด
sodium_crypto_sign_verify_detached() รับ public key ดิบ 32 ไบต์ ไม่ใช่ PEM/DER
เอา public_key (SPKI DER base64) ไปใส่จะได้ SodiumException: public key size should be CRYPTO_SIGN_PUBLICKEYBYTES bytes ⇒ ใช้ field raw_public_key แล้ว base64_decode() ให้ได้ 32 ไบต์
PHP — ❌ ผิด
<?php
$data = json_decode(file_get_contents('php://input'), true); // ❌ #1 บาง middleware อ่านไปแล้ว = สตริงว่าง
$body = json_encode($data); // ❌ #2 re-encode! PHP escape "/" เป็น "\/" และไทยเป็น \uXXXX = ไบต์ต่างจากที่เซ็น
$sig = $_SERVER['HTTP_X_PG_SIGNATURE'];
$ok = openssl_verify($body, base64_decode($sig), $pubKeyPem, OPENSSL_ALGO_SHA256); // ❌ #3 ไม่รองรับ Ed25519 คืน -1 เสมอ
if ($ok != 0) { // ❌ #4 -1 != 0 เป็นจริง ⇒ รับ payload ปลอมทุกใบ
// ❌ #5 ไม่มี timestamp check ไม่มี dedupe
// ❌ #6 ไม่เช็ค livemode ⇒ event ทดสอบเครดิตเงินจริง
creditUser($data['data']['object']['metadata']['user_id'],
$data['data']['object']['credited_amount_minor']); // ❌ #7 ผิดตัวเลข
echo json_encode(['status' => 'ok', 'debug' => $data]); // ❌ #8 body ใหญ่ + leak ข้อมูล
}
// ❌ #9 ไม่ผ่าน = ไม่ตอบอะไร = PHP ตอบ 200 อัตโนมัติ ⇒ ปลอม webhook แล้วได้ 200 ทันที| # | ผลที่เกิดขึ้นจริง |
|---|---|
| 1, 2 | ไบต์ที่เอาไป verify ไม่ใช่ไบต์ที่เราเซ็น — พังทันทีเมื่อมีอักษรไทยหรือ URL ที่มี / |
| 3, 4 | 🔴 รับ payload ปลอมทุกใบ — openssl_verify คืน -1 เมื่อ algorithm ไม่รองรับ และ -1 != 0 เป็นจริง · ต้องเทียบ === true จาก sodium_crypto_sign_verify_detached เท่านั้น |
| 5 | replay ได้ไม่จำกัด + เครดิตซ้ำทุก retry |
| 6 | event ของ sandbox เครดิตเงินจริง (ถ้าตั้ง endpoint ผิดกลุ่ม) |
| 7 | ผู้ใช้ได้เงินขาดเท่ากับค่าธรรมเนียม |
| 9 | 🔴 ร้ายแรงที่สุด — PHP ตอบ 200 โดยอัตโนมัติเมื่อ script จบ ⇒ การ verify ที่ไม่ exit เท่ากับไม่ได้ verify |
ดึง public key
GET https://api.example-wl.com/v1/webhooks/signing-keys{
"keys": [
{
"kid": "whk_2026a",
"alg": "Ed25519",
"public_key": "MCowBQYDK2VwAyEA...",
"format": "spki_der_base64",
"raw_public_key": "5Nn2q...",
"status": "active",
"not_before": "2026-02-01T00:00:00.000Z",
"not_after": null
},
{
"kid": "whk_2026b",
"alg": "Ed25519",
"public_key": "MCowBQYDK2VwAyEA...",
"format": "spki_der_base64",
"raw_public_key": "7Xk4p...",
"status": "next",
"not_before": "2026-09-01T00:00:00.000Z",
"not_after": null
},
{
"kid": "whk_2025z",
"alg": "Ed25519",
"public_key": "MCowBQYDK2VwAyEA...",
"format": "spki_der_base64",
"raw_public_key": "2Mq8t...",
"status": "retiring",
"not_before": "2025-08-01T00:00:00.000Z",
"not_after": "2026-09-08T00:00:00.000Z"
}
]
}| เรื่อง | กติกา |
|---|---|
| auth | ไม่ต้อง — public key เป็นของสาธารณะ |
| rate limit | 60 ครั้ง/นาที/IP |
| รูปแบบ key | ส่งมาทั้ง public_key (SPKI DER base64 — สำหรับ Node/OpenSSL) และ raw_public_key (32 ไบต์ base64 — สำหรับ libsodium/PHP) ⇒ ไม่ต้องแปลง DER เอง |
| cache | ≤ 24 ชั่วโมง |
เจอ kid ที่ไม่รู้จัก | refetch 1 ครั้ง (throttle 1 ครั้ง/นาที) ก่อนตัดสินว่าไม่ผ่าน — 🔴 ห้าม refetch ทุก request ที่ verify ไม่ผ่าน นั่นคือช่องให้คนอื่นทำให้คุณยิงเราเป็นลูป |
status | active = ใช้เซ็นอยู่ตอนนี้ · next = ประกาศล่วงหน้า · retiring = ยังต้องเก็บไว้ verify ของเก่า |
จะ pin key ลง ENV ก็ได้ แต่ต้อง subscribe webhook.key.rotating และมีขั้นตอนรับ kid ใหม่ ไม่งั้นวันสลับกุญแจคุณจะ verify ไม่ผ่านทั้งระบบ
หมุนกุญแจโดยไม่ต้องหยุดรับ
T-7 วัน เผยแพร่กุญแจใหม่เป็น status="next" + ยิง webhook.key.rotating
⚠️ ใบนี้เซ็นด้วยกุญแจ "ปัจจุบัน" ไม่ใช่กุญแจใหม่ (ไม่งั้นคุณ verify ไม่ได้ — ไก่กับไข่)
T สลับ: กุญแจใหม่ → active · กุญแจเก่า → retiring (ยังอยู่ในชุด)
🔴 กุญแจ retiring ต้องอยู่ต่ออย่างน้อยเท่ากับความยาวหน้าต่าง retry (92 ชม.) + buffer
T+8 วัน กุญแจเก่าออกจากชุด (not_after ผ่านไปแล้ว)retry ของ event เก่าใช้กุญแจไหน
เรา เซ็นใหม่ทุก attempt ด้วยกุญแจที่ active ณ ตอนนั้น (เพราะ t ต้องเป็นค่าใหม่)
⇒ merchant ที่ยังไม่อัปเดตกุญแจจะเริ่ม fail ทันทีที่สลับ แม้แต่กับ event เก่าที่ค้าง retry อยู่
⇒ นี่คือเหตุผลที่เราประกาศล่วงหน้า 7 วัน และเป็นเหตุผลที่ webhook.key.rotating ปิดรับไม่ได้
webhook.key.rotating ส่ง next_kid · next_public_key · next_raw_public_key · activates_at มาให้ครบ ⇒ ถ้าคุณ pin key ไว้ ให้ handler เขียนค่าใหม่ลง store แล้ว alert ทีมทันที
กรณีฉุกเฉิน (สงสัยว่ากุญแจรั่ว): เราสลับทันที + ยิง webhook.key.rotating ด้วยกุญแจเก่าเป็นครั้งสุดท้าย + อีเมล + banner · จะมี delivery fail ช่วงสั้น ๆ แล้วเราส่งซ้ำให้ทีหลัง ⇒ handler ที่ cache key ไว้ 24 ชม. ต้อง refetch เมื่อเจอ kid ไม่รู้จักตามกติกาข้างบน ไม่งั้นช่วงนั้นจะ fail ยาว
Test vector — ทดสอบโค้ดโดยไม่ต้องยิง API
GET https://api.example-wl.com/v1/webhooks/test-vectorsได้มา 3 ชุด แต่ละชุดมี raw_body.bin, t, event_id, kid, signature, public_key:
| ชุด | payload | จับบั๊กอะไร |
|---|---|---|
| 1 | ภาษาไทยล้วน | re-serialize (PHP escape ไทยเป็น \uXXXX) |
| 2 | มี URL ที่มี / | PHP escape / เป็น \/ |
| 3 | มี emoji + surrogate pair | การอ่าน body เป็น string แทน bytes |
verify ผ่านทั้ง 3 ชุดก่อนค่อยต่อของจริง
ทั้งสามชุดออกแบบมาให้ โค้ดที่ re-serialize จะผ่านชุดที่ไม่มีอะไรพิเศษ แต่ตกชุดที่ 1–3 — ถ้าเทสต์ด้วย payload ภาษาอังกฤษล้วนอย่างเดียว บั๊กนี้จะไปโผล่บน production
เทสต์ที่ควรมีคู่กัน: negative test — แก้ raw body 1 ไบต์แล้ว verify ต้องได้ 401 · ถ้ายังผ่าน แปลว่าโค้ดคุณไม่ได้ verify จริง
ขั้นต่อไป
| อ่านต่อ | เมื่อ |
|---|---|
| ลำดับไม่การันตี — version guard | verify ผ่านแล้ว กำลังจะเขียน business logic |
| Retry และ endpoint ล่ม | อยากรู้ว่าตอบ 401 แล้วเราจะทำอะไรต่อ |
| ทดสอบก่อนขึ้น production | อยากยิง event เข้าเครื่องตัวเอง |
| แคตตาล็อก event | ต้องการดูใบเต็มของแต่ละ event |
| ยืนยันตัวตนของ request ขาออก | ต้องเซ็น request ที่ คุณ ยิงมาหาเรา (คนละกลไก — HMAC-SHA256) |
