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

# Webhook events

> Every event type, when it fires, and what the payload carries.

All events share one envelope. See [Webhooks](/guides/webhooks) for registration,
signature verification, and retries.

```json theme={null}
{
  "event_id": "d9c8b7a6-...",
  "event_type": "payout.payment_received",
  "api_version": "2026-08-05",
  "created_at": "2026-08-16T14:38:14.000Z",
  "tenant_id": "ten_01HX...",
  "data": {
    "object": "payout",
    "id": "a3f1c2d4-...",
    "status": "PAYMENT_RECEIVED"
  }
}
```

| Field         | Notes                                                                   |
| ------------- | ----------------------------------------------------------------------- |
| `event_id`    | Stable across every retry of the same event. Use it as your dedupe key. |
| `event_type`  | Lowercase dotted form. Branch on this.                                  |
| `api_version` | Date-stamped payload contract, currently `2026-08-05`.                  |
| `created_at`  | When the event was created, not when it was delivered.                  |
| `data`        | Full entity snapshot at the time of the event — never a diff.           |

## Payout events

All eight fire today.

| Event                     | Fires when                                      | Payout status      |
| ------------------------- | ----------------------------------------------- | ------------------ |
| `payout.created`          | The request passed pre-flight checks            | `CREATED`          |
| `payout.pending_payment`  | An instruction was issued — settlement is due   | `PENDING_PAYMENT`  |
| `payout.payment_received` | Your settlement arrived and confirmed           | `PAYMENT_RECEIVED` |
| `payout.processing`       | Dispatched to the local processor               | `PROCESSING`       |
| `payout.successful`       | The beneficiary received the funds              | `SUCCESSFUL`       |
| `payout.failed`           | Delivery failed after retries were exhausted    | `FAILED`           |
| `payout.expired`          | The instruction lapsed unpaid; no money arrived | `EXPIRED`          |
| `payout.refunded`         | A refund was raised against the failed payout   | `FAILED`           |

`payout.successful`, `payout.failed`, and `payout.expired` are terminal — nothing
further arrives for that payout, except `payout.refunded` and the `refund.*` family
after a failure.

## Refund events

All four fire today.

| Event               | Fires when                                    |
| ------------------- | --------------------------------------------- |
| `refund.created`    | The refund exists, status `PENDING`           |
| `refund.processing` | Accepted by the settlement engine             |
| `refund.successful` | Your money is back                            |
| `refund.failed`     | Undeliverable after retries — needs attention |

<Warning>
  **Refund events carry a payout snapshot.** `data.object` is `"payout"` and
  `data.id` is the **payout** ID, not the refund ID. There is no refund object in
  the payload and no refund amount in it.

  Distinguish them by `event_type`, then call
  `GET /v1/payouts/{data.id}/refunds` for the amounts, the destination, and the
  refund's own status. Do not read `data.status` as the refund's status — it is the
  payout's.
</Warning>

## Not currently emitted

These types are defined in the contract and reserved, but nothing emits them yet.
Do not build logic that waits on one:

`inflow.received`, `inflow.matched`, `outflow.dispatched`, `outflow.successful`,
`outflow.failed`

The information is available on the payout events and by reading
[`/v1/inflows`](/guides/settlement#inspecting-settlements) and
`/v1/payouts/{id}/outflows` directly.

## Payload

The snapshot is the same for every event type — only `status` and the timestamps
differ.

```json theme={null}
{
  "event_id": "d9c8b7a6-...",
  "event_type": "payout.processing",
  "api_version": "2026-08-05",
  "created_at": "2026-08-16T14:38:45.000Z",
  "tenant_id": "ten_01HX...",
  "data": {
    "object": "payout",
    "id": "a3f1c2d4-...",
    "status": "PROCESSING",
    "merchant_reference": "order_1041",
    "destination_type": "MOBILE_MONEY",
    "destination_country": "GH",
    "destination_currency": "GHS",
    "destination_amount": 500,
    "destination_provider_name": "MTN",
    "destination_provider_code": "MTN_GH",
    "destination_account_unique_masked": "******0000",
    "destination_account_name": "Kwame Mensah",
    "settlement_currency": "USDT",
    "settlement_amount": 42.32,
    "exchange_rate": 12.2265,
    "total_fee_amount": 1.41,
    "total_fee_currency": "USDT",
    "is_refunded": false,
    "payment_instruction": {
      "id": "b7e2f1a0-...",
      "rail": "CRYPTO",
      "account_unique": "TXyz1234...abc",
      "provider_name": "USDT (TRC-20)",
      "network": "TRC20",
      "expected_amount": 42.32,
      "expected_currency": "USDT",
      "payment_status": "PAID",
      "expires_at": "2026-08-16T15:00:00.000Z",
      "paid_at": "2026-08-16T14:38:14.000Z"
    },
    "created_at": "2026-08-16T14:30:00.000Z",
    "updated_at": "2026-08-16T14:38:45.000Z"
  }
}
```

`payment_instruction` is the payout's current instruction, carried on every event
that has one — so `payout.pending_payment` alone is enough to show the sender where
to pay, with no follow-up call. It is `null` only on `payout.created`, before the
instruction has been generated.

`account_unique` inside `payment_instruction` is **our** receiving address or
virtual account — where the merchant settles — not the beneficiary's account. After
settlement the same object records what was paid: `payment_status` becomes `PAID`
and `paid_at` is set.

The beneficiary appears as `destination_account_name` plus
`destination_account_unique_masked` (last 4 only). Once name enquiry is enabled, the
name is the **bank-confirmed** one, which can differ from the name you supplied —
that difference is worth logging on your side.

### What the payload does not contain

<AccordionGroup>
  <Accordion title="The full beneficiary account number" icon="user-shield">
    Only `destination_account_unique_masked` is sent — the last 4 digits, enough to
    recognise the beneficiary in an alert or a support thread, not enough to pay
    them.

    Every payload is stored verbatim in the delivery log, which is readable via
    `GET /v1/webhooks/deliveries` and kept indefinitely. A full account number
    there would be unprotected PII at rest, so it stays out. You already hold the
    full value from your own create request — correlate on `data.id` or
    `merchant_reference`.
  </Accordion>

  <Accordion title="The status timeline" icon="clock-rotate-left">
    Not included. Read `status_timeline` from `GET /v1/payouts/{id}` when you need
    the history rather than the current state.
  </Accordion>

  <Accordion title="Refund amounts" icon="rotate-left">
    Not included, even on `refund.*` events. Use
    `GET /v1/payouts/{id}/refunds`.
  </Accordion>
</AccordionGroup>

## Handling them well

```typescript theme={null}
switch (payload.event_type) {
  case 'payout.pending_payment':
    // The instruction is in the payload — show it to the sender directly
    return showInstruction(payload.data.payment_instruction);

  case 'payout.successful':
    return markOrderPaid(payload.data.merchant_reference);

  case 'payout.expired':
    // Terminal, and no money ever moved — safe to release the order
    return releaseOrder(payload.data.merchant_reference);

  case 'payout.failed':
    // Not terminal for your money — a refund follows if you had paid
    return flagForReview(payload.data.id);

  case 'refund.successful':
    // data.id is the PAYOUT id, not the refund id
    return reconcileRefund(payload.data.id);

  default:
    // Unknown types are added without a version bump — never throw here
    return acknowledge();
}
```

<Note>
  Ignore unrecognised `event_type` values rather than erroring. New types ship
  without a version bump, and a handler that throws on the unfamiliar starts
  failing deliveries — and burning retries — the day one appears.
</Note>

Deliveries can arrive out of order. Treat `data.status` as the truth and ignore an
event that would move your record backwards from where it already is.
