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 ≤ 300 seconds.
  • 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 Set with a persistent key-value store (set-if-not-exists with a 24-hour TTL) or a database table with a unique constraint on eventId. 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.

Partner API.
Same engine as 0trace.

A private partner integration surface. Signed quotes, server-side pricing, webhook delivery, multiple reference codes, and a self-serve cabinet — all backed by the production exchange engine.

Support

Questions? Answers.

Get the latest updates

Follow us on X

The 0trace team will never ask for KYC or AML. We retain no logs, metadata, or tracking cookies.

Learn more