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

# Webhooks

> Signed, retried, replayable event delivery — and every event you can subscribe to.

Money moves after your HTTP request returns. Webhooks are how you find out.

## Setup

1. Create an endpoint — in the dashboard under **Developers → Webhooks**, or:

```bash theme={null}
POST /v1/webhooks/endpoints
{ "url": "https://example.com/chipper/webhooks", "events": ["payout.completed", "payout.failed"] }
```

Use `["*"]` to receive everything. The response includes the endpoint's **signing secret** (`whsec_…`); it's also available from `GET /v1/webhooks/signing-secret/{endpointId}`.

2. Respond **2xx quickly** (under 15 seconds). Do the work asynchronously.
3. Send yourself a test: `POST /v1/webhooks/endpoints/{id}/test` — or the **Send test** button in the dashboard.

## Payload

```json theme={null}
{
  "event": "payout.completed",
  "data": {
    "id": "pay_ghs_p95akjfs339uy9icpelj",
    "status": "completed",
    "externalReference": "inv-1042",
    "to": { "code": "gh_mtn", "amount": "150.00", "currency": "GHS", "…": "…" },
    "…": "…"
  }
}
```

`data` is the resource exactly as `GET` would return it, so one parser serves both.

## Verify the signature

Deliveries follow the [**Standard Webhooks**](https://www.standardwebhooks.com) spec, so any off-the-shelf library verifies them:

```text theme={null}
webhook-id:        whd_…
webhook-timestamp: 1787518199
webhook-signature: v1,K5oZfzN95Z9UVu1EsPOqzSc6Hc6qGgNXY0ZCL6ci8MM=
```

The signature is HMAC-SHA256 over `{webhook-id}.{webhook-timestamp}.{raw body}` with the base64-decoded bytes after `whsec_`.

<CodeGroup>
  ```ts Node theme={null}
  import { Webhook } from "standardwebhooks";

  const wh = new Webhook(process.env.CHIPPER_WEBHOOK_SECRET!); // whsec_…

  app.post("/chipper/webhooks", express.raw({ type: "*/*" }), (req, res) => {
    const payload = wh.verify(req.body, req.headers as Record<string, string>); // throws if invalid
    // payload.event, payload.data
    res.sendStatus(200);
  });
  ```

  ```python Python theme={null}
  from standardwebhooks import Webhook

  wh = Webhook(os.environ["CHIPPER_WEBHOOK_SECRET"])  # whsec_…

  @app.post("/chipper/webhooks")
  def handle():
      payload = wh.verify(request.get_data(), request.headers)  # raises on failure
      # payload["event"], payload["data"]
      return "", 200
  ```
</CodeGroup>

Verify against the **raw body** — re-serialising JSON changes the bytes. Reject timestamps older than a few minutes to defeat replays (the libraries do this for you).

## Retries and disabling

If your endpoint doesn't return 2xx, delivery retries on this ladder: **30s, 2m, 8m, 32m, 2h, 6h, 6h, 6h** — about a day. After **10 consecutive failed attempts** the endpoint is auto-disabled (`status: "disabled"`, `disabledReason: "consecutive_failures"`); fix it and re-enable with `PATCH /v1/webhooks/endpoints/{id} { "status": "active" }`. Any delivery can be re-sent by hand: `POST /v1/webhooks/deliveries/{id}/retry`, or the **Retry** button in the dashboard.

## Write idempotent handlers

The same event can arrive twice (a retry after a timeout you actually handled). Key your handler on `data.id` + `data.status` and make a repeat a no-op. Don't infer order from arrival time — the payload's `status` is the truth.

## Events

| Event                                                    | Fires when                                                           |
| -------------------------------------------------------- | -------------------------------------------------------------------- |
| `payout.completed`                                       | funds delivered                                                      |
| `payout.failed`                                          | provider rejected or timed out; balance refunded                     |
| `collection.pending`                                     | charge created, prompt sent                                          |
| `collection.completed`                                   | payer approved, balance credited                                     |
| `collection.failed`                                      | declined or timed out                                                |
| `account.credited`                                       | any inflow landed on a balance (deposits, collections)               |
| `conversion.completed`                                   | conversion executed                                                  |
| `order.awaiting_funds`                                   | order created, deposit instructions issued                           |
| `order.awaiting_confirmations`                           | crypto seen on-chain                                                 |
| `order.funds_received`                                   | inflow confirmed                                                     |
| `order.overpaid` / `order.underpaid`                     | inflow differs from the requested amount — order proceeds on actuals |
| `order.processing_payout`                                | converting / paying out                                              |
| `order.completed`                                        | payout delivered                                                     |
| `order.failed`                                           | payout failed; funds remain on your balance                          |
| `order.expired`                                          | no inflow before the deadline                                        |
| `dispute.created` / `dispute.updated` / `dispute.closed` | a dispute was opened on a payment, changed, or resolved              |
| `webhook.test`                                           | you pressed the test button                                          |

Events and deliveries are browsable: `GET /v1/webhooks/events`, `GET /v1/webhooks/deliveries?status=failed` — the same log the dashboard shows.
