Webhooks
Webhooks are the source of truth. Credit the customer only here (or on a verified server-side GET) — never on the browser redirect or a client event. The webhook and the redirect race; either can arrive first.
Register an endpoint
Two equal paths, both returning the whsec_ signing secret exactly once at creation:
- Portal UI — Webhooks → Endpoints.
- Machine API —
POST /api/v1/webhook_endpoints(plusGETlist/one,PATCH,DELETE). A key manages only its own Platform's endpoints.
Subscribe a live endpoint to at least payment.succeeded, payment.failed, payment.expired, and checkout_session.completed.
The event envelope
Every delivery POSTs one event as JSON:
{
"id": "evt_01J9ZZC4N8RH",
"object": "event",
"type": "payment.succeeded",
"api_version": "v1",
"environment": "live",
"merchant_id": "mch_01J7Q2W8XN0F",
"created_at": "2026-07-05T09:58:03Z",
"data": {
"object": { "id": "pay_01J9ZX8KT2M1", "object": "payment", "status": "succeeded", "amount": "500.00", "asset_code": "USDT.TRC20" }
}
}The data.object is the full Payment resource, including the metadata you sent (echoed back). Current- phase event types: payment.created, payment.requires_action, payment.under_review, payment.processing, payment.succeeded, payment.failed, payment.cancelled, payment.expired, checkout_session.completed, checkout_session.expired. Ignore any type you do not handle — the catalog grows additively.
Verify the signature
v1=HMAC-SHA256(secret, "{t}.{raw_request_body}"), hex-encoded. The secret is the endpoint'swhsec_...signing secret.- Compute over the raw bytes, before any JSON parsing. Compare with a constant-time function.
- Reject if
|now − t| > 300seconds (5-minute tolerance) to block replay.
// Verify BEFORE parsing, over the RAW body (api-design §7.2)
[$t, $v1] = sscanf($request->header('PayXiro-Signature'), 't=%d,v1=%s');
$expected = hash_hmac('sha256', $t . '.' . $request->getContent(), $endpointSecret);
if (! hash_equals($expected, $v1) || abs(time() - $t) > 300) {
abort(400); // bad signature or stale (>5 min) — reject
}# Signed delivery headers
PayXiro-Event-Id: evt_01J9ZZC4N8RH
PayXiro-Signature: t=1751709483,v1=5f3a8e...Route the receiver with no CSRF and no auth — the signature is the auth.
Dedupe and ack
Deliveries are at-least-once and unordered. Dedupe on the evt_ id (insert-or-ignore into a store kept 30+ days); if you have seen it, ack 200 and stop. Ack fast (any 2xx within 10 seconds), then do the real work — queue it if non-trivial. Make crediting idempotent on payment.id, so a replayed webhook and a status-poll can never double-credit.
Retries and replay
- Success = any 2xx within 10 seconds. Anything else (including timeouts) schedules a retry.
- Retry schedule after the initial attempt: 1m, 5m, 30m, 2h, 12h, 24h, 24h (8 attempts total). While retries remain the delivery sits in
failed_retrying; once exhausted it is markedexhausted(terminal). - Manual replay: a button on the delivery log in the Merchant Portal (attempts, response codes, and bodies are all inspectable there). A machine-API replay endpoint ships additively in a later phase.
Next: test it in the sandbox, including the .04 double-delivery case that proves your dedupe.