Skip to content
Partner API Docs
← 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 — enforce a unique constraint on payload eventId 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();
const WEBHOOK_SECRET = process.env.MYSTOCKS_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) throw new Error('Set MYSTOCKS_WEBHOOK_SECRET before starting the receiver');

// 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', 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. eventId is stable across every retry. Enforce a UNIQUE constraint on it.
  if (!event.eventId) return res.status(400).send('missing eventId');
  const fresh = await db.insertIgnoreDuplicate('webhook_events', {
    key: event.eventId,
    eventId: event.eventId,
    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 stops after 6 failed attempts: immediate, then 5 s, 30 s, 5 min, 30 min, and 2 h. 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.Return a non-2xx response on purpose, fire another test, and observe the six-attempt retry schedule in GET /webhooks/{id}/deliveries.