<!--
  SINGLE-SOURCE SYNC (ADR-0021): canonical prose for /docs/webhooks, mirrored 1:1 by
  resources/js/pages/public/docs/webhooks.tsx — edit BOTH in the same change (engineering-rules P4).
  Sourced from docs/01-architecture/10-api-design.md §7 and docs/01-architecture/13-brokeret-crm-connector.md §4.
  The PHP verification snippet mirrors the shared React component
  resources/js/components/shared/webhook-signature-sample.tsx (WEBHOOK_SIGNATURE_PHP) — keep them in step.
-->

# 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, and the browser can be manipulated or never arrive.

## 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` (plus `GET` list/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:

```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 (your `account_ref`
and lookup keys, 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

Headers on every delivery:

```
PayXiro-Event-Id: evt_01J9ZZC4N8RH
PayXiro-Signature: t=1751709483,v1=5f3a8e...
```

- `v1` = `HMAC-SHA256(secret, "{t}.{raw_request_body}")`, hex-encoded. The secret is the endpoint's
  `whsec_...` signing secret.
- Compute over the **raw bytes**, before any JSON parsing. Compare with a constant-time function.
- Reject if `|now − t| > 300` seconds (5-minute tolerance) to block replay.

```php
// 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
}
```

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 you
keep for 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 it is non-trivial. Make your 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 marked `exhausted`
  (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](/docs/testing), including the `.04` double-delivery case that proves
your dedupe.
