Webhook Signature Verification
Every PAX webhook delivery carries an X-PAX-Signature header. This page shows exactly how to verify it, including behavior during a 24h dual-secret grace window after you rotate.
The signature header
Format:
X-PAX-Signature: t=<unix_ms>,v1=<hex>[,v1=<hex>]
Each v1= value is HMAC-SHA256(secret, "<t>.<raw_body>") in lowercase hex. The signed message is the timestamp, a literal dot, and the request body exactly as received on the wire — do not re-serialize JSON, do not trim whitespace.
During the 24h grace after a rotate the header contains two v1= values: the first signed with the new secret, the second with the previous secret. Accept if any one matches your configured secret. Outside of grace only one v1= is present.
Verification algorithm
- Read X-PAX-Signature and the raw request body (before JSON parsing).
- Split on , and pull the t= value and every v1= value.
- Reject if |now - t| is above your replay tolerance (recommend 5 minutes).
- Compute HMAC-SHA256(your_secret, t + "." + raw_body).
- Constant-time compare against each v1=. Accept on first match.
Python (Flask)
import hmac, hashlib, time, os
from flask import request, abort
SIGNING_SECRETS = [os.environ["PAX_WEBHOOK_SECRET_CURRENT"],
os.environ.get("PAX_WEBHOOK_SECRET_PREVIOUS", "")]
SIGNING_SECRETS = [s for s in SIGNING_SECRETS if s]
TOLERANCE_MS = 5 * 60 * 1000
@app.post("/webhook/pax")
def pax_webhook():
header = request.headers.get("X-PAX-Signature", "")
raw = request.get_data() # exact bytes — do not use request.json
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
ts = int(parts.get("t", "0"))
if abs(int(time.time() * 1000) - ts) > TOLERANCE_MS:
abort(400, "signature timestamp outside tolerance")
presented = [v for k, v in (p.split("=", 1) for p in header.split(",")) if k == "v1"]
message = f"{ts}.{raw.decode()}".encode()
for secret in SIGNING_SECRETS:
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
for sig in presented:
if hmac.compare_digest(expected, sig):
return "", 200
abort(401, "no v1 signature matched")
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRETS = [process.env.PAX_WEBHOOK_SECRET_CURRENT,
process.env.PAX_WEBHOOK_SECRET_PREVIOUS].filter(Boolean);
const TOLERANCE_MS = 5 * 60 * 1000;
app.post("/webhook/pax", express.raw({ type: "*/*" }), (req, res) => {
const header = req.header("x-pax-signature") ?? "";
const parts = Object.fromEntries(
header.split(",").map((p) => { const [k, ...v] = p.split("="); return [k, v.join("=")]; })
);
const ts = Number(parts.t);
if (Math.abs(Date.now() - ts) > TOLERANCE_MS) return res.sendStatus(400);
const presented = header.split(",").filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
const message = `${ts}.${req.body.toString("utf8")}`;
for (const secret of SECRETS) {
const expected = crypto.createHmac("sha256", secret).update(message).digest("hex");
for (const sig of presented) {
if (expected.length === sig.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
return res.sendStatus(200);
}
}
}
res.sendStatus(401);
});
Bash / curl (probe your own webhook)
# Fire a test event and inspect the header your receiver got
API_KEY=sk_live_...
WH_ID=wh_...
curl -sX POST "https://api.predictasiax.com/v1/webhooks/$WH_ID/test" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"event_type":"trade.filled","data":{"probe":true}}'
# Then in your receiver logs: count the v1= entries. During grace = 2.
The rotation flow (Stripe-model)
- Call POST /v1/webhooks/{id}/rotate-secret.
- Response contains the new signing_secret, plus rotated_at_ms, grace_expires_at_ms, and grace_window_ms (currently 86400000 = 24h).
- Store BOTH the new and previous secret in your receiver's config. Both should be in the accept list.
- Deploy the new receiver code. All deliveries during grace verify against either secret.
- After grace_expires_at_ms, drop the old secret. Deliveries then carry only one v1=.
Disabling a webhook
DELETE /v1/webhooks/{id} is soft-disable: the row stays with active=false, no further deliveries fire. To resume events, register a fresh webhook — disabled endpoints cannot be re-enabled by design (event ordering guarantees).
Event catalog
| Event | Fires when |
|---|---|
| trade.filled | Each fill on a market you subscribed to |
| market.resolved | Market moved to a terminal outcome by the oracle stack |
| payout.available | A payout batch is ready for you to claim |
| payout.paid | Funds transferred out to your payout wallet |
| auth.token.revoked | An OAuth token or API key was revoked |
Spec version 2.5.0 · Last updated 2026-09-25