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.
The Headers
The signed message is the timestamp, a literal
., and the raw request body bytes — exactly as received, before any JSON parsing.
The Verification Recipe
- 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
tandv1fromX-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. - Compare with
v1using a constant-time comparison.
Node.js / TypeScript
Python
request.get_data() returns the raw bytes):
Operational Rules
- Respond
2xxwithin 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. Return2xxfor deliveries you choose to skip — a4xxstill 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
400on 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.
Testing Your Verifier
You don’t need to wait for a real event. Compute a signature yourself and POST it:T=$(($(date +%s) - 3600))).
Related
Stay in Sync
Subscribing, the event catalog, retries, and the agent loop around deliveries.
Platform Webhooks
The platform-side delivery surface — same signature scheme, plus redelivery.