Skip to content

ตรวจลายเซ็น 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 จากที่ไหนต้อง authGET /v1/webhooks/signing-keys ไม่ต้อง auth

สิ่งที่คุณต้องทำเพิ่มจากที่เคยทำกับ HMAC

  1. เลิกใช้ crypto.createHmac — มันจะไม่มีวัน verify ผ่าน และไม่มีค่า shared secret ในระบบนี้ให้ใช้
  2. Node ใช้ crypto.verify(null, ...) · PHP ใช้ sodium_crypto_sign_verify_detached()
  3. ดึง public key มา cache เอง แล้ว handle กรณีเจอ kid ที่ไม่รู้จัก

Header ที่เราส่ง ​

http
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-Idevent.id — ใช้ประกอบ canonical string
X-PG-Event-Typeชื่อ event — ใช้ route/log ก่อน parse
X-PG-Delivery-Idid ของการส่งครั้งนี้ · log ไว้ทุกครั้ง
X-PG-Endpoint-Idendpoint ปลายทางของการส่งครั้งนี้
X-PG-Attemptรอบที่เท่าไร
X-PG-Resendไม่มี header นี้ใน delivery ปกติ · เป็น manual เมื่อกดส่งซ้ำ · ไม่ใช่ส่วนหนึ่งของ event
X-PG-Api-Versionversion ที่ render ใบนี้
X-PG-Livemodetrue / 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 ชั้น ​

ชั้นทำอะไรไม่ผ่านตอบอะไร
1verify ลายเซ็นบน raw bytes401
2abs(now − t) > 300 วินาที400
3dedupe ด้วย event.id ใน transactional inbox — เก็บอย่างน้อย 31 วัน🔴 200

ชั้นที่ 3 ต้องตอบ 200 ไม่ใช่ 409

ตอบ non-2xx เมื่อเจอ event ซ้ำ = เรานับว่า fail = เรา retry ต่อ = คุณโดนถล่มด้วย event เดิม 11 รอบ

event ซ้ำเป็นเรื่องปกติมาก (retry ที่ response หายกลางทาง, การกดส่งซ้ำ) — มันไม่ใช่ error

sql
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 — ✅ ถูก ​

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 — ❌ ผิด ​

ทุกคอมเมนต์ในโค้ดนี้คือบั๊กที่เจอจริงในโปรดักชัน

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, 3verify พังแบบสุ่มเมื่อ 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 เท่านั้น
12metadata.user_id อาจไม่มี; ต้องใช้ correlation ที่ persist จาก create request+response
9เกิน 15 วิ → นับเป็น fail → retry → ทำงานซ้ำ → ช้าลงอีก → วนจนตาย
11ตอบ 500 ทุกครั้งที่ลายเซ็นผิด = 11 รอบต่อ event × ทุก event = ถล่มตัวเอง

PHP — ✅ ถูก ​

ใช้ libsodium ที่มากับ PHP core ตั้งแต่ 7.2 ไม่ต้องลง extension เพิ่ม

php
<?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
<?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 เท่านั้น
5replay ได้ไม่จำกัด + เครดิตซ้ำทุก retry
6event ของ sandbox เครดิตเงินจริง (ถ้าตั้ง endpoint ผิดกลุ่ม)
7ผู้ใช้ได้เงินขาดเท่ากับค่าธรรมเนียม
9🔴 ร้ายแรงที่สุด — PHP ตอบ 200 โดยอัตโนมัติเมื่อ script จบ ⇒ การ verify ที่ไม่ exit เท่ากับไม่ได้ verify

ดึง public key ​

http
GET https://api.example-wl.com/v1/webhooks/signing-keys
json
{
  "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 limit60 ครั้ง/นาที/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 ไม่ผ่าน นั่นคือช่องให้คนอื่นทำให้คุณยิงเราเป็นลูป
statusactive = ใช้เซ็นอยู่ตอนนี้ · 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 ​

http
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 guardverify ผ่านแล้ว กำลังจะเขียน business logic
Retry และ endpoint ล่มอยากรู้ว่าตอบ 401 แล้วเราจะทำอะไรต่อ
ทดสอบก่อนขึ้น productionอยากยิง event เข้าเครื่องตัวเอง
แคตตาล็อก eventต้องการดูใบเต็มของแต่ละ event
ยืนยันตัวตนของ request ขาออกต้องเซ็น request ที่ คุณ ยิงมาหาเรา (คนละกลไก — HMAC-SHA256)

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01