<!-- Source: https://docs.infraio.xyz/en/webhooks/overview -->
<!-- Last updated: 2026-10-04 -->

# Webhooks — Overview

Webhooks are the **authoritative** signal. Browser callbacks
(`onSuccess`) and dashboard views are convenience; webhooks are
ground truth.

## Delivery guarantees

- **At least once.** A single event may be delivered up to **6 times**
  if your server doesn't return 2xx within the timeout. Make your
  handler idempotent — dedup on `X-Delivery` (the payload has no
  `event_id` field; the stable delivery UUID is the idempotency key).
- **One event per HTTP request.** No batching.
- **Per-endpoint isolation.** If you have multiple registered
  endpoints, each gets its own delivery and retry track. One slow
  endpoint doesn't delay the others.
- **Signed.** Every payload carries an `X-Signature` header (and
  during the 24-hour window after a rotation, also an
  `X-Signature-Prev`). Verify before doing anything with the body.
  See [Signature verification](https://docs.infraio.xyz/en/webhooks/signature-verification).

## Subscribable event types

| Event | Fires when… |
| --- | --- |
| `payment.settled` | The on-chain transfer cleared the chain's confirmation count. **Use this to mark orders paid.** |
| `payment.failed` | A fiat payment was rejected by the payment provider. Not fired for crypto timeouts — those surface as `checkout.expired` instead, and short crypto payments surface as `payment.underpaid`. |
| `payment.underpaid` | Funds arrived but short of the order total (typical: stablecoin transfer fee taken from the amount). |
| `payment.overpaid` | Funds arrived in excess of the order total. The surplus is recorded but not auto-refunded. |
| `order.created` | A new order was opened — either by your B2B API call or by a checkout-session conversion. |
| `order.canceled` | An order moved to cancelled. The payload's `data.reason` distinguishes manual cancel from `payment_timeout` (an unpaid order timed out). |
| `order.resolved` | A `PARTIAL_PAID` order was resolved to `PAID` — the merchant accepted the shortfall. |
| `order.reopened` | A previously auto-canceled order (`canceled_reason=payment_timeout`) was reopened by the merchant. |
| `checkout.created` | A buyer opened the checkout for an order. |
| `checkout.completed` | The buyer-side flow finished (does not imply on-chain settlement — use `payment.settled` for that). |
| `checkout.expired` | The buyer abandoned and the session TTL ran out. |
| `payment.refund.requested` | A refund record was created — either from a merchant-initiated API call or from a customer-submitted refund-request form. |
| `payment.refund.approved` | A pending refund passed your approval workflow. |
| `payment.refund.rejected` | A pending refund was denied. |
| `payment.refund.executed` | The refund's on-chain transfer cleared and the record moved to terminal `executed`. |
| `refund_request.created` | A refund-request token was minted. `data.source` is `b2b` / `dashboard` / `renewal`. Optional to subscribe — useful for audit pipelines that track which token is currently active per order. |
| `refund_request.renewal_requested` | A buyer clicked "Request new link" after their token expired. **Strongly recommended to subscribe** — this is the merchant's cue that the renewal widget has a new item to act on. |
| `refund_request.renewed` | A renewal was approved and a new token replaced the old one. `data.old_token` / `data.new_token` form the audit chain. |
| `refund_request.canceled` | A merchant flipped a token to `CANCELED` from the dashboard (e.g. declined a renewal request, killed a live link). Idempotent — only the first transition emits. `data.reason` is the optional merchant note. |

### Planned (Coming soon)

> **Note:**
>
> **Coming soon.** These events belong to recurring invoices and
> subscriptions, which are not yet available. They are **not** in the
> subscribable table above and cannot be subscribed to today. See
> [Recurring invoices](https://docs.infraio.xyz/en/guides/recurring-invoices).

| Planned event | Fires when… |
| --- | --- |
| `subscription.created` | A subscription is created. |
| `invoice.created` | An invoice for a billing cycle is created. |
| `invoice.paid` | An invoice is paid. |
| `subscription.past_due` | An invoice is unpaid past its due date. |
| `subscription.canceled` | A subscription is canceled. |

The dashboard's endpoint form lists the same events. Subscribing to an
event that doesn't exist is rejected when you save the endpoint.

> **Note:**
>
> **Test events aren't subscribable.** The dashboard's per-endpoint
> **Send Test** button sends a `webhook.test.ping` event to that one
> endpoint immediately, without retries. It isn't in the catalog above:
> you receive it because you have a registered endpoint, not because you
> subscribed.

> **Note:**
>
> Subscribe to only the events you handle. Each endpoint has its own
> event filter; the wildcard `"*"` means "every event, including ones
> added in the future". Subscribing to fewer events keeps your handler
> simpler and means fewer retries when your endpoint has errors.

## Payload + headers

**The HTTP body is the event-specific data object directly.** No outer
Stripe-style envelope — fields like the event type, delivery ID, and
emission timestamp live in **headers** instead. For `payment.settled`
the body looks like:

```json
{
  "receipt_id":        "rcp_…",
  "order_id":          "ord_…",
  "payment_intent_id": "pin_…",
  "checkout_session_id": "cst_…",
  "merchant_id":       "mer_…",
  "customer_id":       "cus_…",
  "total":             "49.00",
  "currency":          "USD",
  "payment_method":    "crypto",
  "token":             "USDC",
  "network":           "polygon",
  "tx_hash":           "0x…",
  "deposit_address":   "0x…",
  "treasury_address":  "0x…",
  "amount_received":   "49.00",
  "confirmations":     5,
  "metadata":          { /* per-event */ }
}
```

Other events carry their own fields. Field names are stable (lower
snake_case); the on-chain transaction hash is always `tx_hash`.

`tx_hash` is the transaction identifier in the network's own format (`0x…` on EVM chains; the native hash or signature on TRON, Solana and TON). `deposit_address` can be absent for TRON, Solana and TON, because buyers pay your treasury wallet directly there, and `confirmations` follows [Chains & assets](https://docs.infraio.xyz/en/concepts/chains).

### Headers on the inbound request

```http
Content-Type:      application/json
X-Event:           payment.settled
X-Delivery:        7c9e6679-7425-40de-944b-e07fc1f90ae7
Idempotency-Key:   7c9e6679-7425-40de-944b-e07fc1f90ae7
X-Timestamp:       1729536000
X-Signature:       sha256=9a8b7c…
X-Signature-Prev:  sha256=fa31b2…    (only during a rotation grace window)
```

| Header | What it is |
| --- | --- |
| `X-Event` | The event type (e.g. `payment.settled`). Route on this at the proxy layer if you want to skip JSON parsing. |
| `X-Delivery` | UUID identifying the delivery row. **Stable across all retries** of the same `(event, endpoint)` pair — use it as your idempotency key. |
| `Idempotency-Key` | Mirrors `X-Delivery` (same value). Set on every delivery. |
| `X-Timestamp` | Unix-seconds when the attempt was sent. Signed into the payload so a captured `(body, X-Signature)` pair can't be replayed indefinitely — reject deliveries whose timestamp is outside your tolerance window. |
| `X-Signature` | `sha256=<hex>` of `HMAC-SHA256(secret, X-Timestamp + "." + raw_body)`. See [Signature verification](https://docs.infraio.xyz/en/webhooks/signature-verification). |
| `X-Signature-Prev` | Same algorithm with the **previous** secret. Present only in the 24-hour window after you rotate — lets verifiers running either key keep accepting deliveries during the cutover. After the window closes the header stops being sent. |

## Retry schedule

If your endpoint doesn't return `2xx` within the timeout, we retry
on this schedule (timestamps relative to first attempt):

| Attempt | Delay | Cumulative |
| --- | --- | --- |
| 1 | 0s | 0s |
| 2 | +1 min | 1m |
| 3 | +5 min | 6m |
| 4 | +15 min | 21m |
| 5 | +1 hour | 1h 21m |
| 6 | +6 hours | 7h 21m |

After attempt 6 fails, the delivery is marked **Failed** and
your account email is notified. You can replay failed events from the
dashboard's **Developers → Webhooks → Delivery
history** panel. Each replay is a new delivery with its own `X-Delivery`.

## Register an endpoint

From the [merchant dashboard](https://app.infraio.xyz):

1. **Developers → Webhooks** → **+ Add endpoint**
2. Paste your URL — `https://…` only (plain HTTP is rejected; the
   create form also blocks `localhost`, private IP ranges, and URLs
   carrying userinfo)
3. Choose events to subscribe (or `*` for all)
4. Pick environment — **test** or **live** (each gets its own
   secret; they never cross over)
5. Save → the dashboard displays the signing secret (`whsec_…`)
   **once**. Store it server-side; you'll need it for the next two
   features.

You can register up to **10 endpoints per environment per merchant**
(e.g., one for production fulfillment, one for staging mirroring, one
for a Slack notifier). Each has its own retry state and secret.

## Lifecycle actions on each endpoint

The ⋮ menu on each endpoint card surfaces:

- **Edit** — change the URL, description, or subscription list.
  The new URL is re-validated with the same `https://`/SSRF rules
  as create.
- **Send Test** — synchronously POSTs a `webhook.test.ping` envelope
  signed with your current secret. The dashboard shows the HTTP
  status, latency, and a 512-byte snippet of your response.
  Test pings aren't retried, so you get an immediate answer.
- **Rotate Secret** — generates a new secret. The previous one stays
  valid for **24 hours** (deliveries carry both `X-Signature` and
  `X-Signature-Prev` during the window so verifiers running either
  key keep accepting events while you redeploy).
- **Reveal Secret** — re-displays the existing secret. Gated by
  fresh 2FA verification and recorded in the audit log; use it only
  when you lost your copy and Rotate isn't acceptable.
- **Enable / Disable** — switch the endpoint on or off without losing delivery
  history. Disabled endpoints stay in the dashboard but receive no
  new deliveries.
- **Delete** — permanent. Use Disable if you might re-enable later.

## Tips for handlers

1. **Return 2xx fast.** Acknowledge with `200 OK` before doing heavy
   work — move fulfillment to a background job. The per-attempt
   timeout is **10 seconds**; holding the response longer than that
   triggers a retry. The timeout is platform-side and not
   merchant-configurable — contact support if your handler genuinely
   needs more time.
2. **Dedup on `X-Delivery`** (or `Idempotency-Key` — same value).
   Even if you return 2xx, an upstream proxy could drop the
   connection and trigger a retry; the delivery ID is stable across
   every retry of the same delivery row, so it's the right key.
3. **Tolerate unknown event types.** New events may appear; return
   200 and no-op rather than 4xx, or those deliveries will keep retrying.
4. **Log `X-Delivery` next to your business logic.** When something
   goes wrong, that's the join key between our side and yours.

## What's next

- [Signature verification](https://docs.infraio.xyz/en/webhooks/signature-verification) —
  the exact algorithm + replay-protection patterns.
- [Concepts → Sessions](https://docs.infraio.xyz/en/concepts/sessions) — what state a session
  is in when each event fires.
