← 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
| Step | Endpoint / Event | What it does |
|---|---|---|
| 1 | POST /webhooks | Register your endpoint URL, the event list, and a signing secret (min 16 chars). |
| 2 | x-mystocks-signature | Verify HMAC-SHA256 of the RAW request body with a constant-time comparison before parsing. |
| 3 | Respond 2xx fast | You have 8 seconds. Acknowledge immediately, process asynchronously (queue/job). |
| 4 | Dedupe + idempotent handler | The same event can be delivered more than once — enforce a unique constraint on payload eventId and make processing idempotent. |
| 5 | POST /webhooks/{id}/test | Fire a test.event delivery to prove reachability and signature handling before go-live. |
| 6 | GET /webhooks/{id}/deliveries | Delivery log with per-attempt status — your source of truth for gaps after all retries fail. |
| 7 | GET /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.