PredictAsiaX API Docs

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.

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

  1. Read X-PAX-Signature and the raw request body (before JSON parsing).
  2. Split on , and pull the t= value and every v1= value.
  3. Reject if |now - t| is above your replay tolerance (recommend 5 minutes).
  4. Compute HMAC-SHA256(your_secret, t + "." + raw_body).
  5. Constant-time compare against each v1=. Accept on first match.
Rotation window. When you rotate, you get a new whsec_*. Immediately store it alongside the old one and try both in the verify path — do NOT delete the old secret until grace_expires_at_ms has passed. Standard "single stored secret" implementations will start dropping the second delivery within seconds of rotation.

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)

  1. Call POST /v1/webhooks/{id}/rotate-secret.
  2. Response contains the new signing_secret, plus rotated_at_ms, grace_expires_at_ms, and grace_window_ms (currently 86400000 = 24h).
  3. Store BOTH the new and previous secret in your receiver's config. Both should be in the accept list.
  4. Deploy the new receiver code. All deliveries during grace verify against either secret.
  5. After grace_expires_at_ms, drop the old secret. Deliveries then carry only one v1=.
What can go wrong. A receiver that reads its secret from an env var and does not support two-at-a-time will lose the first delivery after rotate (signed with new secret it does not have yet) OR every delivery after grace expiry (still trying old). Always support two secrets briefly.

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

EventFires when
trade.filledEach fill on a market you subscribed to
market.resolvedMarket moved to a terminal outcome by the oracle stack
payout.availableA payout batch is ready for you to claim
payout.paidFunds transferred out to your payout wallet
auth.token.revokedAn OAuth token or API key was revoked

Spec version 2.5.0 · Last updated 2026-09-25