Skip to main content

Webhook receiver

Problem: Stand up a production-grade endpoint that verifies Moda webhook signatures, rejects replays, deduplicates, acknowledges fast, and hands off event processing to a background worker.

Primitives

  • Raw request body (for HMAC)
  • X-Webhook-Signature + X-Webhook-Timestamp headers
  • Webhook signing secret (stored with the API key — not the API key itself)
  • Event envelope id as the dedupe key
  • Async worker (queue / task runner / Pub-Sub topic) for the actual work

TypeScript — Express

Python — FastAPI

What to do per event type

Always log data.error.request_id on failures — it’s the handle support will use to find your request.

Gotchas

  • Use raw body for HMAC. Express / FastAPI default JSON parsing hides the exact bytes. express.raw or await req.body() (not await req.json() before reading body).
  • Return 200 in < 30s, always. On a non-2xx or timeout, Moda makes up to 3 delivery attempts total (initial + 2 retries, ~1s then ~5s apart), then drops.
  • Dedupe on event.id, not event.data.id. Same task can have multiple events (succeeded vs failed, though rare; future: export for the same task). Using event.id dedupes retries of the same event; using task.id would dedupe legitimate different events.
  • TTL your dedupe keys. 7 days is plenty — Moda’s retry window is minutes.
  • Reject stale timestamps. 5 minutes is the conventional bound. Anything older is almost certainly a replay attack.
  • Use constant-time compare (timingSafeEqual / compare_digest). Raw == leaks timing info.
  • HTTPS only. Moda won’t deliver to plain-HTTP callback_url.
  • Don’t do work inside the handler. Enqueue, then 200. If Postgres is down or Slack is slow, you want to 200 anyway and retry the downstream work yourself — not force Moda to retry.
  • Test with the wrong signature in staging to make sure you reject. A handler that silently accepts unsigned bodies is the worst case.

Local testing without public HTTPS

Point the task’s callback_url at the ngrok HTTPS URL. Signing still works — the signing secret is per-API-key, not per-URL.

See also