Authenticate OpenTrain webhook deliveries with HMAC signature verification, in Node.js and Python.
Every webhook delivery from OpenTrain is signed with the subscription’s secret (the whsec_... value returned once, when the subscription was created). Verifying the signature proves the delivery came from OpenTrain and wasn’t tampered with — never act on an unverified delivery.This scheme is shared by the Public API and the Platform API: one verifier works for both.
Read the raw body bytes. Not JSON.parse-then-re-stringify — key ordering and whitespace differences will change the bytes and the verification will fail (or worse, falsely pass a forged body if you re-serialize attacker-controlled JSON).
Parse t and v1 from X-OpenTrain-Signature.
Reject if |now − t| exceeds your tolerance window (5 minutes is a good default) — this bounds replay attacks.
Compute HMAC-SHA256(secret, "<t>.<rawBody>") and hex-encode it.
In frameworks that parse JSON for you (Express with express.json(),
Next.js API routes, Fastify), you must opt into the raw body —
e.g. express.raw({ type: 'application/json' }) on the webhook route, or
the verify callback that captures req.rawBody. Verifying a re-serialized
body is the single most common webhook-verification bug.
Respond 2xx within 10 seconds. Acknowledge immediately and do real work asynchronously. A slow handler is indistinguishable from a failing one and burns your retry budget.
Return 5xx (or time out) to request a retry. Deliveries retry up to 5 attempts with 1m/5m/30m/120m backoff. Return 2xx for deliveries you choose to skip — a 4xx still counts as a failure toward auto-disable.
Dedupe by X-OpenTrain-Delivery. Retries reuse the delivery ID; treat it as an idempotency key. (If you follow the trigger-then-poll pattern, duplicates are naturally harmless.)
A 400 on signature failure is fine — but log it. Repeated signature failures usually mean a body-parsing middleware is mangling the raw bytes, or you rotated the subscription (new secret) without updating your handler.
Secrets are per-subscription. If you run multiple subscriptions, key your secrets by webhook ID. Deleting and re-creating a subscription issues a new secret.