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 secret | Both sides hold the same secret | We hold the private key · you hold the public key |
| Our database leaks | An attacker can forge webhooks to you immediately | Nothing to forge with — no private key in hand |
| Your database leaks | An attacker can forge webhooks to you as well | The public key is public anyway; nothing is lost |
| How you store the key | Must live in a secret manager and rotate on schedule | Keep it in ENV or commit it (but you must subscribe to webhook.key.rotating) |
| Where you fetch it | Requires auth | GET /v1/webhooks/signing-keys, no auth |
What changes compared with an HMAC integration
- Stop using
crypto.createHmac— it will never verify, and there is no shared secret in this system to use. - Node uses
crypto.verify(null, ...)· PHP usessodium_crypto_sign_verify_detached(). - Fetch and cache the public key yourself, and handle the case of an unknown
kid.
Headers we send
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 | meaning |
|---|---|
X-PG-Signature | Fixed 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-Id | event.id — needed to build the canonical string. |
X-PG-Event-Type | Event name — useful for routing and logging before you parse. |
X-PG-Delivery-Id | Id of this delivery · log it every time. |
X-PG-Endpoint-Id | Destination endpoint for this delivery. |
X-PG-Attempt | Which attempt this is. |
X-PG-Resend | Absent on normal deliveries; manual on a manual resend · not part of the event. |
X-PG-Api-Version | The version this delivery was rendered with. |
X-PG-Livemode | true / false |
The X-PG- prefix | Always 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_BYTESConceptually (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.
tis bound into the signature ⇒ changing the timestamp breaks the signature ⇒ the replay window is actually enforceable.event_idis 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
| layer | what it does | what to answer on failure |
|---|---|---|
| 1 | Verify the signature over the raw bytes | 401 |
| 2 | abs(now − t) > 300 seconds | 400 |
| 3 | Dedupe 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.
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
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 NULLwithFOR UPDATE SKIP LOCKED, publishes withdedupe_key, then setspublished_at. A crash after publish may create a duplicate, but cannot lose the event; the worker remains idempotent onevent_id.To use
raw_public_key(32 bytes) with Node, wrap it into SPKI yourself with the fixed prefixBuffer.concat([Buffer.from('302a300506032b6570032100','hex'), raw32])and pass that tocreatePublicKeythe same way.
Node.js — ❌ wrong
Every comment in this snippet is a bug that has actually shipped to production somewhere.
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, 3 | Verification fails at random whenever a payload contains Thai characters or emoji — passes QA, then breaks in production. |
| 2 | The day we add a header field is the day your code breaks. |
| 4 | It will never verify — and PG_SECRET does not exist in this system. |
| 5 | A 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, 7 | The 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. |
| 12 | metadata.user_id may be absent; use the correlation persisted from the create request and response. |
| 9 | Over 15 s → counted as a failure → retry → repeated work → slower still → death spiral. |
| 11 | Answering 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
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
$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, 2 | The 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. |
| 5 | Unlimited replay, plus a duplicate credit on every retry. |
| 6 | Sandbox events credit real money (if an endpoint is configured in the wrong group). |
| 7 | The 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
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"
}
]
}| topic | rule |
|---|---|
| auth | Not required — public keys are public. |
| rate limit | 60 requests/minute/IP |
| key formats | We 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 kid | Refetch 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. |
status | active = 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
GET https://api.example-wl.com/v1/webhooks/test-vectorsYou get three sets, each with raw_body.bin, t, event_id, kid, signature, public_key:
| set | payload | bug it catches |
|---|---|---|
| 1 | Thai text only | Re-serialization (PHP escapes Thai as \uXXXX) |
| 2 | Contains a URL with / | PHP escapes / as \/ |
| 3 | Contains emoji + a surrogate pair | Reading 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 next | when |
|---|---|
| Order is not guaranteed — version guard | Verification works and you are about to write business logic. |
| Retry and endpoint outages | You want to know what we do after you answer 401. |
| Testing before production | You want real events delivered to your own machine. |
| Event catalogue | You need the full body of each event. |
| Authenticating your outbound requests | You need to sign requests you send to us (a different mechanism — HMAC-SHA256). |
