Skip to content

Verify the webhook signature (Ed25519) ​

Every webhook we send is signed with Ed25519. Do not touch anything in the body until verification passes.

Why Ed25519 and not HMAC ​

HMAC (what other gateways use)Ed25519 (ours)
Who holds the secretBoth sides hold the same secretWe hold the private key · you hold the public key
Our database leaksAn attacker can forge webhooks to you immediatelyNothing to forge with — no private key in hand
Your database leaksAn attacker can forge webhooks to you as wellThe public key is public anyway; nothing is lost
How you store the keyMust live in a secret manager and rotate on scheduleKeep it in ENV or commit it (but you must subscribe to webhook.key.rotating)
Where you fetch itRequires authGET /v1/webhooks/signing-keys, no auth

What changes compared with an HMAC integration

  1. Stop using crypto.createHmac — it will never verify, and there is no shared secret in this system to use.
  2. Node uses crypto.verify(null, ...) · PHP uses sodium_crypto_sign_verify_detached().
  3. Fetch and cache the public key yourself, and handle the case of an unknown kid.

Headers we send ​

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
headermeaning
X-PG-SignatureFixed shape v1,kid=<kid>,t=<unix_seconds>,s=<base64url, no padding> · 🔴 parse it as key=value, never by position — we may add fields later.
kid (inside the signature)Id of the key that signed this delivery — needed during key rotation.
t (inside the signature)Unix seconds at which the signature was produced ⇒ it changes on every attempt and on every manual resend.
s (inside the signature)The 64-byte Ed25519 signature, encoded base64url with no =.
X-PG-Event-Idevent.id — needed to build the canonical string.
X-PG-Event-TypeEvent name — useful for routing and logging before you parse.
X-PG-Delivery-IdId of this delivery · log it every time.
X-PG-Endpoint-IdDestination endpoint for this delivery.
X-PG-AttemptWhich attempt this is.
X-PG-ResendAbsent on normal deliveries; manual on a manual resend · not part of the event.
X-PG-Api-VersionThe version this delivery was rendered with.
X-PG-Livemodetrue / false
The X-PG- prefixAlways the same. It does not change per white-label brand.

Keep the event separate from its delivery

Business-event data is in the body. delivery_id, endpoint_id, attempt, and resend live only in headers because they can change on each attempt.

You need X-PG-Event-Id to build the canonical string before verification, and after verification must check it equals event.id in the body. Delivery headers are not part of the canonical signature. Log them for observability only after the event verifies; never use them for money, authorization, or business-state decisions.

The canonical string we sign ​

We sign bytes, not a parsed string:

signed_bytes = utf8("v1.") ‖ utf8(t) ‖ utf8(".") ‖ utf8(event_id) ‖ utf8(".") ‖ RAW_BODY_BYTES

Conceptually (one line, no newline separators):

v1.1755749553.evt_01K3M8QW7Z4N2VYB6H0RSTA9CD.{"id":"evt_01K3M8QW7Z4N2VYB6H0RSTA9CD","object":"event",...}

Why it is built this way

  • No separate hashing step ⇒ fewer ways for merchants to get it wrong.
  • t is bound into the signature ⇒ changing the timestamp breaks the signature ⇒ the replay window is actually enforceable.
  • event_id is bound in ⇒ bodies cannot be swapped between two deliveries.
  • Raw bytes ⇒ key order, unicode escaping, and whitespace stop mattering entirely.

Never re-serialize

We do not guarantee JSON key order, but one event's raw body is byte-for-byte identical across retries and manual resends. Only delivery headers, t, and the signature change.

JSON.stringify(req.body) / json_encode($data) produces a different byte sequence from the one we signed ⇒ random verification failures, especially when the payload contains Thai characters or emoji. This is the hardest bug to debug on this list, because it passes when you test with English-only payloads.

Replay protection — all three layers are required ​

layerwhat it doeswhat to answer on failure
1Verify the signature over the raw bytes401
2abs(now − t) > 300 seconds400
3Dedupe on event.id in a transactional inbox, retained at least 31 days🔴 200

Layer 3 must answer 200, not 409

Answering non-2xx for a duplicate event means we count it as a failure, so we retry, so you get bombarded with the same event 11 times.

Duplicate events are completely normal (a retry whose response was lost in transit, a manual resend). They are not errors.

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;

Retain inbox rows for at least 31 days because an event can be manually resent until day 30 from its created_at. Deleting at day 7 allows the same event to be processed again.

Node.js — ✅ correct ​

js
import express from 'express';
import crypto from 'node:crypto';
const app = express();

// 🔑 must be the raw body — do not use express.json() on this path
app.post('/webhooks/pg', express.raw({ type: '*/*', limit: '512kb' }), async (req, res) => {
  const raw = req.body;                                  // raw Buffer
  const header = req.get('X-PG-Signature') || '';

  // 1) parse the header as key=value, never by position
  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) ±5 minute replay window (checked before verifying, to save CPU)
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(skew) || skew > 300) return res.status(400).end();

  // 3) the header event id is only used to build the canonical string (nothing is trusted yet)
  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 only *after* verification
  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 must commit together. Never markSeen and enqueue separately.
  //    If the database is unavailable, this transaction rolls back and the source retries.
  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();               // duplicate = 200, not 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 to 1/minute
  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;
}

The outbox relay reads rows where published_at IS NULL with FOR UPDATE SKIP LOCKED, publishes with dedupe_key, then sets published_at. A crash after publish may create a duplicate, but cannot lose the event; the worker remains idempotent on event_id.

To use raw_public_key (32 bytes) with Node, wrap it into SPKI yourself with the fixed prefix Buffer.concat([Buffer.from('302a300506032b6570032100','hex'), raw32]) and pass that to createPublicKey the same way.

Node.js — ❌ wrong ​

Every comment in this snippet is a bug that has actually shipped to production somewhere.

js
app.use(express.json());                              // ❌ #1 swallows the raw body

app.post('/webhooks/pg', async (req, res) => {
  const sigHeader = req.headers['x-pg-signature'];
  const [, kidPart, tPart, sPart] = sigHeader.split(','); // ❌ #2 parsing by position
  const t = tPart.split('=')[1], sig = sPart.split('=')[1];

  const body = JSON.stringify(req.body);              // ❌ #3 re-serialized! key order and unicode differ from the real bytes

  const expected = crypto.createHmac('sha256', process.env.PG_SECRET)   // ❌ #4 HMAC, when this is Ed25519
                         .update(t + '.' + body).digest('hex');

  if (expected == sig) {                              // ❌ #5 == (timing attack) + comparing hex against base64url
    // ❌ #6 no timestamp check → replayable forever
    // ❌ #7 no dedupe on event.id → the 3rd retry credits again
    await creditUser(req.body.data.object.metadata.user_id, // ❌ #12 optional metadata
                     req.body.data.object.credited_amount_minor);  // ❌ #8 wrong number
    await sendEmail(); await syncToERP(); await rebuildReport();   // ❌ #9 over 15 s = timeout = retry
    res.json({ ok: true });                                        // ❌ #10 needlessly large body
  } else {
    res.status(500).send('bad sig');                               // ❌ #11 500 = we retry 11 times
  }
});
#what actually happens
1, 3Verification fails at random whenever a payload contains Thai characters or emoji — passes QA, then breaks in production.
2The day we add a header field is the day your code breaks.
4It will never verify — and PG_SECRET does not exist in this system.
5A plain string comparison opens a timing attack, and the two encodings mean the result was wrong from the start ⇒ use crypto.verify, which is constant-time internally.
6, 7The customer is credited again on every retry — up to 11×.
8🔴 The end user is shortchanged by the fee on every order — use amount_received_minor · credited_amount_minor applies to the merchant wallet only.
12metadata.user_id may be absent; use the correlation persisted from the create request and response.
9Over 15 s → counted as a failure → retry → repeated work → slower still → death spiral.
11Answering 500 on every bad signature = 11 attempts per event × every event = you DDoS yourself.

PHP — ✅ correct ​

Uses libsodium, which ships with PHP core from 7.2 onwards. No extra extension needed.

php
<?php
declare(strict_types=1);

// 🔑 raw body — read it as early as possible (some frameworks have already consumed php://input; use $request->getContent() there)
$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);              // 🔴 32 raw bytes, decoded from 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;                       // already seen
}
$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 become durable together
http_response_code(200);

The PHP gotcha that wastes the most time

sodium_crypto_sign_verify_detached() takes a raw 32-byte public key, not PEM or DER.

Feeding it public_key (SPKI DER, base64) gives you SodiumException: public key size should be CRYPTO_SIGN_PUBLICKEYBYTES bytes ⇒ use the raw_public_key field and base64_decode() it to 32 bytes.

PHP — ❌ wrong ​

php
<?php
$data = json_decode(file_get_contents('php://input'), true);   // ❌ #1 some middleware already read it = empty string
$body = json_encode($data);   // ❌ #2 re-encoded! PHP escapes "/" as "\/" and Thai as \uXXXX = different bytes from what we signed
$sig  = $_SERVER['HTTP_X_PG_SIGNATURE'];

$ok = openssl_verify($body, base64_decode($sig), $pubKeyPem, OPENSSL_ALGO_SHA256);  // ❌ #3 no Ed25519 support, always returns -1
if ($ok != 0) {               // ❌ #4 -1 != 0 is true ⇒ every forged payload is accepted
    // ❌ #5 no timestamp check, no dedupe
    // ❌ #6 no livemode check ⇒ test events credit real money
    creditUser($data['data']['object']['metadata']['user_id'],
               $data['data']['object']['credited_amount_minor']);   // ❌ #7 wrong number
    echo json_encode(['status' => 'ok', 'debug' => $data]);          // ❌ #8 large body + data leak
}
// ❌ #9 no match = no response written = PHP answers 200 automatically ⇒ a forged webhook gets 200 straight away
#what actually happens
1, 2The bytes you verify are not the bytes we signed — it breaks the moment there is a Thai character or a URL containing /.
3, 4🔴 Every forged payload is accepted. openssl_verify returns -1 for an unsupported algorithm, and -1 != 0 is true · only compare === true from sodium_crypto_sign_verify_detached.
5Unlimited replay, plus a duplicate credit on every retry.
6Sandbox events credit real money (if an endpoint is configured in the wrong group).
7The user is shortchanged by the fee.
9🔴 The worst one — PHP returns 200 automatically when the script ends ⇒ verification without an exit is not verification at all.

Fetching the 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"
    }
  ]
}
topicrule
authNot required — public keys are public.
rate limit60 requests/minute/IP
key formatsWe send both public_key (SPKI DER base64 — for Node/OpenSSL) and raw_public_key (32 bytes base64 — for libsodium/PHP) ⇒ you never have to unwrap DER yourself.
cache≤ 24 hours
unknown kidRefetch once (throttled to 1/minute) before declaring failure — 🔴 never refetch on every failed verification; that is how someone else turns you into a loop hammering us.
statusactive = currently signing · next = announced in advance · retiring = still needed to verify older deliveries.

You may pin the key in ENV, but only if you subscribe to webhook.key.rotating and have a procedure for accepting the new kid. Otherwise every verification fails on switchover day.

Rotating keys with no downtime ​

T-7 days   Publish the new key with status="next" + fire webhook.key.rotating
           ⚠️ That delivery is signed with the *current* key, not the new one (otherwise you could not verify it — chicken and egg)

T          Switch: new key → active · old key → retiring (still in the set)
           🔴 The retiring key must stay for at least the length of the retry window (92 h) plus a buffer

T+8 days   The old key leaves the set (not_after has passed)

Which key signs a retry of an old event

We re-sign on every attempt with whichever key is active at that moment (because t must be fresh).

⇒ A merchant that has not picked up the new key starts failing the instant we switch, even for old events still in their retry cycle.

⇒ That is why we announce 7 days ahead, and why webhook.key.rotating cannot be turned off.

webhook.key.rotating carries next_kid · next_public_key · next_raw_public_key · activates_at ⇒ if you pin keys, have your handler write the new values into your store and alert your team straight away.

Emergency case (suspected key compromise): we switch immediately, fire webhook.key.rotating signed with the old key one last time, send email, and show a banner. A short burst of deliveries will fail and we resend them afterwards ⇒ a handler that caches keys for 24 h must refetch on an unknown kid as described above, or it will keep failing throughout that window.

Test vectors — check your code without calling the API ​

http
GET https://api.example-wl.com/v1/webhooks/test-vectors

You get three sets, each with raw_body.bin, t, event_id, kid, signature, public_key:

setpayloadbug it catches
1Thai text onlyRe-serialization (PHP escapes Thai as \uXXXX)
2Contains a URL with /PHP escapes / as \/
3Contains emoji + a surrogate pairReading the body as a string instead of bytes

Pass all three before integrating for real

All three are designed so that re-serializing code passes an ordinary payload but fails sets 1–3. If you only test with English-only payloads, this bug surfaces in production.

Pair it with a negative test: change one byte of the raw body and verification must return 401. If it still passes, your code is not really verifying.

Next ​

read nextwhen
Order is not guaranteed — version guardVerification works and you are about to write business logic.
Retry and endpoint outagesYou want to know what we do after you answer 401.
Testing before productionYou want real events delivered to your own machine.
Event catalogueYou need the full body of each event.
Authenticating your outbound requestsYou need to sign requests you send to us (a different mechanism — HMAC-SHA256).

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