// Node.js 22+ signature/inbox boundary example. Rename to verify-webhook.mjs. // Supply the RAW body bytes BEFORE JSON middleware. Do not log bodies/secrets. import { createHmac, timingSafeEqual } from 'node:crypto'; export function verifyWebhook(rawBody, headers, secret, nowSeconds = Math.floor(Date.now() / 1000)) { if (!Buffer.isBuffer(rawBody) || !secret) throw new Error('Raw bytes and the webhook signing secret are required.'); const timestamp = headers['x-mms-express-timestamp']; const signature = headers['x-mms-express-signature']; const eventId = headers['x-mms-express-event']; if (typeof timestamp !== 'string' || !/^\d{1,12}$/.test(timestamp)) throw new Error('Invalid timestamp.'); // Five minutes is this receiver's policy, not a service delivery guarantee. // The timestamp is regenerated for each delivery attempt, including old events. if (!Number.isSafeInteger(Number(timestamp)) || Math.abs(nowSeconds - Number(timestamp)) > 300) throw new Error('Webhook timestamp outside receiver tolerance.'); if (typeof signature !== 'string' || !/^v1=[0-9a-f]{64}$/.test(signature)) throw new Error('Invalid signature format.'); const expected = createHmac('sha256', secret).update(timestamp + '.', 'utf8').update(rawBody).digest(); const supplied = Buffer.from(signature.slice(3), 'hex'); if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) throw new Error('Signature mismatch.'); const event = JSON.parse(rawBody.toString('utf8')); if (typeof eventId !== 'string' || !/^evt_[0-9a-f]{32}$/i.test(eventId) || event?.id !== eventId) throw new Error('Signed event ID does not match the header.'); if (!['message.received', 'message.sent', 'message.failed'].includes(event.type)) throw new Error('Unsupported event type.'); return event; } export async function acceptWebhook(rawBody, headers, secret, inbox, nowSeconds) { const event = verifyWebhook(rawBody, headers, secret, nowSeconds); // Implement this adapter with YOUR durable database/queue, not an in-memory Set. // Within one transaction: insert event.id under a UNIQUE constraint with payload // and enqueue work. An already committed ID is a successful duplicate. Resolve // concurrent inserts atomically. Reject a changed payload for an existing ID. // The promise must resolve ONLY after commit; throw if storage is unavailable. await inbox.storeOnceAndEnqueue(event.id, rawBody); // Map to HTTP 204 only now. On verification failure send 400/401; on an inbox // failure send 503 so delivery can retry. Apply a bounded raw-body limit too. return { status: 204 }; }