Flows
Webhooks
Receive real-time events instead of polling. Register an HTTPS endpoint, subscribe to the events you care about, and verify the signature on every delivery.
Available events
payment.succeeded | event | optional | A checkout payment cleared; an account is being provisioned |
payment.failed | event | optional | A checkout payment failed |
kyc.updated | event | optional | A trader's KYC status changed |
payout.completed | event | optional | A payout settled to the trader's Connect account |
payout.failed | event | optional | A payout was reversed or could not be settled |
* | wildcard | optional | Subscribe to every event type |
Register an endpoint
Webhook endpoints are tenant-level objects owned by your app, not by an end user. Authenticate with your app's OAuth bearer token carrying the webhooks scope (the api superscope and the * wildcard also satisfy it); a token without any of them is rejected 403 V2_SCOPE_MISSING. No end-user session is involved.
/v2/webhook-endpointsurl | string | required | Your HTTPS receiver |
events | string[] | required | e.g. ["payment.succeeded","kyc.updated"] or ["*"] |
description | string | optional | Internal label |
curl -X POST http://localhost:8000/v2/webhook-endpoints \
-H "Authorization: Bearer <app_access_token>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/api/webhooks",
"events": ["payment.succeeded", "kyc.updated"],
"description": "Production receiver"
}'{
"id": "whe_...",
"url": "https://yourapp.com/api/webhooks",
"events": ["payment.succeeded", "kyc.updated"],
"active": true,
"description": "Production receiver",
"secret": "whsec_... // store it — used to verify signatures"
}Delivery format
Each delivery is a POST with a JSON envelope and signed headers.
Content-Type: application/json
X-Hyperscaled-Event: payment.succeeded
X-Hyperscaled-Delivery: whd_...
X-Hyperscaled-Timestamp: 1750640000
X-Hyperscaled-Signature: t=1750640000,v1=9f86d081...
{
"type": "payment.succeeded",
"data": { "payment_id": "pay_...", "user_id": "usr_...", "stripe_payment_intent_id": "pi_..." },
"timestamp": "2026-06-22T23:13:20+00:00"
}Verify the signature
HMAC-SHA256 over `{timestamp}.{raw_body}` using your endpoint secret. Reject deliveries older than ~5 minutes.
import crypto from "node:crypto";
// Accepts one secret or a comma-separated list, so you can keep the old and
// new secret live at the same time while rotating.
const SECRETS = (process.env.HSC_WEBHOOK_SECRET ?? "")
.split(",").map((s) => s.trim()).filter(Boolean);
export async function POST(req: Request) {
const raw = await req.text();
const header = req.headers.get("x-hyperscaled-signature") ?? "";
// The header may carry SEVERAL v1= signatures during a secret rotation:
// t=1730000000,v1=<new>,v1=<previous>
// Object.fromEntries would silently keep only the last one — collect them all.
let ts = NaN;
const signatures: string[] = [];
for (const part of header.split(",")) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const key = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (key === "t") ts = Number(value);
else if (key === "v1") signatures.push(value);
}
if (!Number.isFinite(ts) || signatures.length === 0) {
return new Response("bad signature", { status: 400 });
}
// Reject stale deliveries (replay protection).
if (Math.abs(Date.now() / 1000 - ts) > 300) return new Response("stale", { status: 400 });
const ok = SECRETS.some((secret) => {
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${raw}`)
.digest("hex");
// timingSafeEqual throws on a length mismatch — guard before comparing.
return signatures.some(
(sig) =>
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
});
if (!ok) return new Response("bad signature", { status: 400 });
const event = JSON.parse(raw); // { type, data, timestamp }
// ...handle event.type
return new Response("ok");
}Handle multiple signatures
v1= per secret. A verifier that parses the header into an object keeps only the last one and will reject every delivery for the whole grace window. Accept the delivery if any signature matches.This app ships a receiver
app/api/hsc-webhook/route.ts in this repo for a working verifier wired to HSC_WEBHOOK_SECRET.List & remove endpoints
/v2/webhook-endpoints[
{
"id": "whe_...",
"url": "https://yourapp.com/api/webhooks",
"events": ["payment.succeeded", "kyc.updated"],
"active": true,
"description": "Production receiver"
}
]Deactivate with DELETE /v2/webhook-endpoints/{id}.