> ## Documentation Index
> Fetch the complete documentation index at: https://docs.infinityapp.in/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive events as payouts, payees, batches and deposits change, verify their signatures and process them reliably.

Payouts complete asynchronously, often minutes or days after you create them, so polling is the wrong model. Instead, register an HTTPS endpoint and Infinity sends it an event, as a JSON `POST`, each time something in your account changes: a payout is paid or returned, a payee becomes active, a deposit is credited.

## Register an endpoint

```bash theme={null}
curl "$BASE/webhook-endpoints" \
  -H "Authorization: Bearer $INFINITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://acme.com/webhooks/infinity",
    "events": ["payout.paid", "payout.failed", "payout.returned", "payee.active"],
    "description": "Production payouts"
  }'
```

```json Response theme={null}
{
  "id": "webhook_qjvv94gq6e73azsa",
  "url": "https://acme.com/webhooks/infinity",
  "events": ["payout.paid", "payout.failed", "payout.returned", "payee.active"],
  "status": "enabled",
  "description": "Production payouts",
  "secret": "whsec_Z2A0bsz0zLOglNjzoXGq31z2z6EoNyFTy8BwdVlejqk=",
  "created_at": "2026-10-26T10:00:00Z",
  "updated_at": "2026-10-26T10:00:00Z"
}
```

> [!WARNING]
>
> `secret` is returned **only** in this response. Store it in your secrets manager; you need it to verify every event delivered to this endpoint. There's no secret rotation in v1: to replace a secret, create a new endpoint and delete the old one.

* The URL must use HTTPS and resolve to a public address. IP literals and hosts that resolve to private, loopback or link-local ranges are rejected, as is a URL already used by another of your endpoints.
* Subscribe to the event types your integration handles. `"events": ["*"]` subscribes to every type, including types added later.
* An account can have up to 5 endpoints. Each event is delivered to every enabled endpoint subscribed to its type.

## The event object

Every delivery's body is an event:

```json theme={null}
{
  "id": "event_2wq8n5hkd7xm3rpa",
  "type": "payout.paid",
  "created_at": "2026-10-26T10:05:30Z",
  "api_version": "v1",
  "environment": "live",
  "data": {
    "object": {
      "id": "payout_4kz9xq7m2w8dn3ra",
      "batch_id": "batch_fcb4cuwtd6fcbmwr",
      "payee_id": "payee_c8pnahcrxqzd3z8d",
      "amount": "500.00",
      "currency": "eur",
      "status": "paid",
      "payee_amount": "500.00",
      "reference": "inv-1042",
      "...": "every other payout field"
    }
  }
}
```

`data.object` is the complete object as it was when the event occurred, in the same shape as the corresponding `GET` endpoint returns, so most handlers don't need to call back. The prefix of `type` identifies the object: `payout`, `batch`, `payee` or `deposit`. `created_at` is when the change was committed, not when the event was delivered. `environment` is `live` or `sandbox`.

## Verify signatures

Your endpoint is a public URL, so anyone can send requests to it. Verify every event's signature before acting on it, so that a forged request can't trigger a fulfilment, a refund or a ledger entry.

Infinity signs events with the open [Standard Webhooks](https://www.standardwebhooks.com) scheme. Each request carries three headers:

| Header | Content |
| - | - |
| `webhook-id` | The event ID. Identical across retries and across endpoints. |
| `webhook-timestamp` | Unix time, in seconds, of this delivery attempt. |
| `webhook-signature` | `v1,` followed by a base64 HMAC-SHA256 signature. |

The official Standard Webhooks libraries verify the signature and reject timestamps more than 5 minutes from your clock, which protects against replayed requests. Always verify against the **raw** request body, before JSON parsing; re-serialised JSON won't match the signature.

```javascript Node.js theme={null}
import express from "express";
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.INFINITY_WEBHOOK_SECRET); // whsec_...
const app = express();

app.post("/webhooks/infinity", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body, req.headers); // throws on a bad signature or timestamp
  } catch {
    return res.sendStatus(400);
  }
  res.sendStatus(200);   // acknowledge first
  queue.push(event);     // then process asynchronously
});
```

```python Python theme={null}
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook

wh = Webhook(os.environ["INFINITY_WEBHOOK_SECRET"])  # whsec_...
app = Flask(__name__)

@app.post("/webhooks/infinity")
def infinity_webhook():
    try:
        event = wh.verify(request.get_data(), dict(request.headers))
    except Exception:
        return "", 400
    queue.put(event)  # process asynchronously
    return "", 200
```

> [accordion] Verifying without a library
>
> 1. Strip the `whsec_` prefix from the secret and base64-decode the remainder; that's the HMAC key.
> 2. Build the signed content: `{webhook-id}.{webhook-timestamp}.{raw body}`.
> 3. Compute HMAC-SHA256 over the signed content with the key, and base64-encode the result.
> 4. Compare it, using a constant-time comparison, with each `v1,` entry in `webhook-signature`. The header can carry several space-separated signatures.
> 5. Reject the request if `webhook-timestamp` is more than 5 minutes from your current time.

## Delivery and retries

```mermaid theme={null}
sequenceDiagram
  participant I as Infinity
  participant E as Your endpoint
  I->>E: Attempt 1
  E-->>I: 500
  Note over I: retry after 1 min
  I->>E: Attempt 2
  E-->>I: timeout (over 10 s)
  Note over I: retry after 5 min, then 30 min, 2 h, 6 h
  I->>E: Attempt 3
  E-->>I: 200, delivered
```

* **Success** is any `2xx` response within 10 seconds. Redirects aren't followed, so a `3xx` counts as a failure, as do timeouts and connection errors.
* **Retries.** A failed delivery is retried 5 times, after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, about 9 hours in total. After that the delivery is abandoned, and the event remains available through the [events API](#recover-missed-events).
* **Signatures** are computed per attempt, so `webhook-timestamp` and `webhook-signature` change between retries while `webhook-id` stays the same.
* **Disabled and deleted endpoints** receive no new deliveries, and pending retries to them are dropped.

## Process events reliably

* **Acknowledge quickly.** Return `2xx` as soon as the signature is verified and the event is queued, and do the work asynchronously. Slow handlers cause timeouts, which cause retries.
* **Handle duplicates.** Delivery is at-least-once, so the same event can arrive more than once. Record processed `webhook-id` values and skip repeats.
* **Don't depend on order.** Events aren't guaranteed to arrive in the order they were created; `payout.paid` can arrive before `payout.processing`. Use the object's `status` to decide what to do, and compare `created_at` only to avoid applying an older snapshot over a newer one.
* **Ignore unknown types.** New event types can be added at any time. Acknowledge them with `2xx` and move on.

## Recover missed events

Every event is retained for 30 days, whether or not it was delivered and whether or not any endpoint subscribed to it:

```bash theme={null}
curl "$BASE/events?type=payout.paid&created[gte]=2026-10-25T00:00:00Z&limit=100" \
  -H "Authorization: Bearer $INFINITY_KEY"
```

After an outage on your side, list events from the start of the outage and process those whose IDs you haven't recorded. `GET /events/{id}` returns a single event; its `data.object` is the snapshot taken when the event occurred, not the object's current state.

## Event types

An object's creation emits only its `.created` event; the object's `status` in that event tells you where it starts. Each later status change emits `<object>.<new status>`.

| Event | Emitted when |
| - | - |
| `payout.created` | A payout is created, with `status` `pending` or `awaiting_payee`. |
| `payout.updated` | A payout on hold is updated. |
| `payout.pending` | A payout on hold is released. |
| `payout.awaiting_funds` | A released payout can't be covered by its wallet. |
| `payout.processing` | A payout is in transit. |
| `payout.paid` | A payout is delivered. |
| `payout.failed` | A payout isn't delivered; see `failure_code`. |
| `payout.returned` | A paid payout is returned by the payee's bank. |
| `payout.canceled` | A payout on hold is canceled. |
| `payout.expired` | A payout on hold expires. |
| `batch.created` | A batch is created by `POST /payouts`. |
| `batch.processing` | A batch's payouts start moving. |
| `batch.awaiting_payees` | A batch has payouts waiting for their payees. It can return to this status after `batch.processing`. |
| `batch.awaiting_funds` | A batch has payouts waiting for funds. |
| `batch.completed` | Every payout in the batch was paid. |
| `batch.partially_completed` | The batch finished with some payouts unpaid. |
| `batch.failed` | The batch finished with no payouts paid. |
| `batch.canceled` | Every payout in the batch was canceled. |
| `payee.created` | A payee is created. |
| `payee.details_pending` | A payee signed up but hasn't submitted bank details. |
| `payee.verifying` | A payee's details are being verified. |
| `payee.active` | A payee can now be paid. |
| `payee.inactive` | A payee was deactivated. |
| `deposit.created` | Funds were credited to a wallet. |

## Manage endpoints

| Request | Effect |
| - | - |
| `GET /webhook-endpoints` | Lists your endpoints. `secret` is `null`. |
| `GET /webhook-endpoints/{id}` | Retrieves one endpoint. |
| `POST /webhook-endpoints/{id}` | Updates `url`, `events` (replacing the list), `description` or `status` (`enabled` or `disabled`). |
| `DELETE /webhook-endpoints/{id}` | Deletes the endpoint and drops its pending retries. |

## Test in the sandbox

Sandbox events carry `"environment": "sandbox"`. To exercise failure handling, force an outcome with the [advance helper](https://docs.infinityapp.in/guides/sandbox.md#force-a-payout-outcome); for example, move a paid payout to `returned`:

```bash theme={null}
curl "$BASE/test/payouts/payout_4kz9xq7m2w8dn3ra/advance" \
  -H "Authorization: Bearer $INFINITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "returned" }'
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.