Skip to content
← All recipes

Handle Webhooks Reliably

≈ half a day

Webhooks are how settlement, deposits, dividends, and corporate actions reach you. Delivery is at-least-once — the same event can arrive twice and out of order — so a production-grade handler verifies the signature on the raw body, responds fast, deduplicates, and reconciles gaps from the delivery log.

The flow

StepEndpoint / EventWhat it does
1POST /webhooksRegister your endpoint URL, the event list, and a signing secret (min 16 chars).
2x-mystocks-signatureVerify HMAC-SHA256 of the RAW request body with a constant-time comparison before parsing.
3Respond 2xx fastYou have 8 seconds. Acknowledge immediately, process asynchronously (queue/job).
4Dedupe + idempotent handlerThe same event can be delivered more than once — dedupe on a natural key and make processing idempotent.
5POST /webhooks/{id}/testFire a test.event delivery to prove reachability and signature handling before go-live.
6GET /webhooks/{id}/deliveriesDelivery log with per-attempt status — your source of truth for gaps after all retries fail.
7GET /stream?since=Optional: the SSE stream mirrors every webhook event and replays up to 1 h of backlog for reconciliation.

Implementation

javascript
const crypto = require('crypto');
const express = require('express');
const app = express();

// IMPORTANT: raw body — signature covers the exact bytes
app.post('/webhooks/mystocks', express.raw({ type: '*/*' }), async (req, res) => {
  // 2. Verify the signature before doing ANYTHING with the payload
  const sig = req.headers['x-mystocks-signature'] ?? '';
  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.MYSTOCKS_WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');
  const valid = sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!valid) return res.status(401).send('invalid signature');

  const event = JSON.parse(req.body);

  // 4. Dedupe on a natural key — at-least-once means duplicates WILL happen.
  //    Use a unique-constrained DB table in production, not an in-memory set.
  const dedupeKey = [
    req.headers['x-mystocks-event'],
    event.data.orderId ?? event.data.transactionId ?? event.data.subAccountId ?? '',
    event.timestamp,
  ].join(':');
  const fresh = await db.insertIgnoreDuplicate('webhook_events', { key: dedupeKey, payload: event });

  // 3. Acknowledge fast; process async. Never do slow work before responding.
  res.status(200).send('ok');
  if (fresh) await queue.enqueue('process-mystocks-event', event);
});

// Handler tips: derive state from the PAYLOAD (order status, balances),
// never from arrival order — a retried order.pending can land after order.filled.

Common mistakes

  • Parsing JSON before verifying the signature. The HMAC covers the raw bytes — any body-parsing middleware that runs first breaks verification.
  • Using string equality (===) for the signature. Use crypto.timingSafeEqual / hmac.compare_digest — plain comparison leaks timing.
  • Assuming exactly-once or in-order delivery. Retries mean duplicates and reordering; dedupe and derive state from the payload.
  • Doing slow work before responding. Past 8 seconds the attempt is marked failed and retried — you will process the same event again.
  • Treating retries as infinite. Delivery gives up after 6 attempts (immediate → 2 h backoff). Sweep GET /webhooks/{id}/deliveries for status "failed" on a schedule.

Prove it in sandbox first

  • 1.Register a sandbox webhook, then fire POST /webhooks/{id}/test — a signed test.event hits your endpoint immediately and is logged.
  • 2.Place a sandbox trade (instant fill) to receive a real order.filled with the exact production payload shape.
  • 3.Kill your endpoint on purpose, fire another test, and watch the retry schedule in GET /webhooks/{id}/deliveries.