Skip to content
OpenTrain AIOpenTrain AIOpenTrain AIDocs

Ask OpenTrain

Answers from the documentation, with sources.

What would you like to do with OpenTrain?

AI answers can be mistaken. Check the linked sources. Don’t include private account information.

Open app

DEVELOPER DOCUMENTATION

Verify Webhook Signatures

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.

X-OpenTrain-Event: proposal.received
X-OpenTrain-Delivery: <delivery id>
X-OpenTrain-Signature: t=<unix seconds>,v1=<hex hmac>

The signature header has two comma-separated parts:

PartMeaning
tUnix timestamp (seconds) of when the delivery was signed
v1Hex-encoded HMAC-SHA256(secret, "<t>.<rawBody>")

The signed message is the timestamp, a literal ., and the raw request body bytes — exactly as received, before any JSON parsing.

  1. 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).
  2. Parse t and v1 from X-OpenTrain-Signature.
  3. Reject if |now − t| exceeds your tolerance window (5 minutes is a good default) — this bounds replay attacks.
  4. Compute HMAC-SHA256(secret, "<t>.<rawBody>") and hex-encode it.
  5. Compare with v1 using a constant-time comparison.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyOpenTrainSignature(
secret: string,
signatureHeader: string,
rawBody: string | Buffer,
toleranceSeconds = 300
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(',').map((kv) => kv.split('=') as [string, string])
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
const provided = String(parts.v1 ?? '');
if (provided.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(provided, 'hex'), Buffer.from(expected, 'hex'));
}

Wired into a minimal HTTP server that preserves the raw body:

import { createServer } from 'node:http';
const SECRET = process.env.OPENTRAIN_WEBHOOK_SECRET!; // the whsec_... value
createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const rawBody = Buffer.concat(chunks);
const signature = String(req.headers['x-opentrain-signature'] ?? '');
if (!verifyOpenTrainSignature(SECRET, signature, rawBody)) {
res.writeHead(400).end('invalid signature');
return;
}
// Acknowledge first, process async.
res.writeHead(200).end('ok');
const event = JSON.parse(rawBody.toString('utf8'));
const deliveryId = String(req.headers['x-opentrain-delivery'] ?? '');
void handleEvent(event, deliveryId);
});
}).listen(8080);
import hashlib
import hmac
import time
def verify_opentrain_signature(
secret: str,
signature_header: str,
raw_body: bytes,
tolerance_seconds: int = 300,
) -> bool:
parts = dict(kv.split("=", 1) for kv in signature_header.split(","))
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance_seconds:
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get("v1", ""), expected)

For example with Flask (request.get_data() returns the raw bytes):

from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["OPENTRAIN_WEBHOOK_SECRET"]
@app.post("/hooks/opentrain")
def opentrain_webhook():
if not verify_opentrain_signature(
SECRET,
request.headers.get("X-OpenTrain-Signature", ""),
request.get_data(),
):
return "invalid signature", 400
delivery_id = request.headers.get("X-OpenTrain-Delivery", "")
enqueue_for_processing(request.get_json(), delivery_id) # process async
return "ok", 200
  • 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.

You don’t need to wait for a real event. Compute a signature yourself and POST it:

Terminal window
SECRET="whsec_test"
BODY='{"id":"1","type":"proposal.received","apiVersion":"v1","resourceId":"x","jobId":null,"data":{}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -sS -X POST http://localhost:8080/ \
-H "Content-Type: application/json" \
-H "X-OpenTrain-Event: proposal.received" \
-H "X-OpenTrain-Delivery: test-1" \
-H "X-OpenTrain-Signature: t=$T,v1=$SIG" \
-d "$BODY"

Your handler should accept this, reject a tampered body (change one character), and reject a stale timestamp (T=$(($(date +%s) - 3600))).