Receiver scaffold
Production-ready webhook receivers in Node.js, Python, and Go.
A correct webhook receiver does five things: read the exact request body bytes before any middleware re-serialises them; verify HMAC-SHA256 with a constant-time compare; check that X-Partner-Webhook-Timestamp is within the 5-minute window; deduplicate by an event-specific key; and return any 2xx HTTP status inside the 10-second budget. The receivers below cover all five for the three event types your partner record may be subscribed to: order.status_changed, partner.paid_out, earnings.cleared. They use an in-process Set for idempotency. Replace it with a persistent store (a key-value cache with TTL, or a database table with a unique constraint). Disable response compression and any body-rewriting middleware on the webhook path.
What the scaffolds share
- Raw body read (no JSON-parse before signature check).
- Constant-time HMAC-SHA256 compare against
X-Partner-Webhook-Sign. - 5-minute timestamp window:
-60 ≤ now − ts ≤ 300seconds. - Per-event idempotency key (
order:<id>:<status>,paid_out:<paidOutAt>,cleared:<clearedAt>). - Fast 2xx (≤ 10 s) with async dispatch of business logic.
Receivers
Pick a language. The receiver listens on PORT (default 4242) and expects the secret in the PARTNER_WEBHOOK_SECRET environment variable.
import express from 'express';
import crypto from 'node:crypto';
const PORT = process.env.PORT || 4242;
const WEBHOOK_SECRET = process.env.PARTNER_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) {
console.error('PARTNER_WEBHOOK_SECRET is required');
process.exit(1);
}
const app = express();
// Capture the raw body BEFORE express.json mutates it.
// The HMAC is computed over these exact bytes.
app.post(
'/webhook',
express.raw({ type: 'application/json', limit: '256kb' }),
(req, res) => {
const rawBody = req.body; // Buffer
const sigHeader = String(req.headers['x-partner-webhook-sign'] ?? '');
const tsHeader = String(req.headers['x-partner-webhook-timestamp'] ?? '');
if (!verifySignature(rawBody, sigHeader, WEBHOOK_SECRET)) {
return res.status(401).end();
}
if (!verifyTimestamp(tsHeader)) {
return res.status(401).end();
}
let payload;
try {
payload = JSON.parse(rawBody.toString('utf8'));
} catch {
return res.status(400).end();
}
if (isReplay(payload)) {
return res.status(200).end(); // idempotent re-acknowledge
}
// 2xx within 10 s. Do the actual work asynchronously.
res.status(202).end();
queueMicrotask(() => dispatch(payload).catch(console.error));
},
);
function verifySignature(rawBody, sigHex, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
let b;
try {
b = Buffer.from(sigHex, 'hex');
} catch {
return false;
}
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function verifyTimestamp(tsStr) {
const ts = Number(tsStr);
if (!Number.isFinite(ts)) return false;
const now = Math.floor(Date.now() / 1000);
return now - ts >= -60 && now - ts <= 300;
}
const seen = new Set(); // production: persistent key-value cache, SET-IF-NOT-EXISTS with 24h TTL
function isReplay(payload) {
const key = idempotencyKey(payload);
if (!key) return false;
if (seen.has(key)) return true;
seen.add(key);
return false;
}
function idempotencyKey(payload) {
switch (payload.event) {
case 'order.status_changed':
return `order:${payload.order?.id}:${payload.order?.status}`;
case 'partner.paid_out':
return `paid_out:${payload.payout?.paidOutAt}`;
case 'earnings.cleared':
return `cleared:${payload.earnings?.clearedAt}`;
default:
return null;
}
}
async function dispatch(payload) {
switch (payload.event) {
case 'order.status_changed':
return onOrderStatusChanged(payload.order);
case 'partner.paid_out':
return onPartnerPaidOut(payload.payout);
case 'earnings.cleared':
return onEarningsCleared(payload.earnings);
default:
console.warn('Unknown event', payload.event);
}
}
async function onOrderStatusChanged(order) {
// your business logic here
console.log('order', order.id, order.status);
}
async function onPartnerPaidOut(payout) {
console.log('paid', payout.totalEarnedUsd, 'at', payout.paidOutAt);
}
async function onEarningsCleared(earnings) {
console.log('cleared', earnings.totalEarnedUsd, 'at', earnings.clearedAt);
}
app.listen(PORT, () => console.log(`webhook receiver on :${PORT}`));Production notes
- Idempotency store. Replace the in-process
Setwith a persistent key-value store (set-if-not-exists with a 24-hour TTL) or a database table with a unique constraint oneventId. A 24-hour TTL is comfortable: re-deliveries never span more than the retry ladder (~4 h 36 m). - Async dispatch. If your business handlers can take longer than the 10-second budget, enqueue and return 2xx immediately. The retry contract is documented on the Webhooks page.
- Logging. Log the
event,ts, and an identifying field (order.id/payout.paidOutAt). Do not log the raw signature or secret. - TLS termination. Serve the receiver over HTTPS in production. The signature alone authenticates the body bytes, but TLS protects against passive eavesdropping of payloads in transit.