> ## 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.

# Refunds

> What happens to your money when a payout fails, and the destination you must register first.

Spendin holds no balance for you. There is no wallet, no ledger, and no withdrawal
endpoint. When a payout fails after your settlement has already arrived, the money
is **sent straight back out** to a destination you registered in advance.

That is why registering the destination is a precondition of creating a payout
rather than a step at refund time. A failed payout can always be returned because
we insisted on knowing where to before we let you start.

## Register a destination

One destination per settlement currency. Setting one again replaces it.

<Tabs>
  <Tab title="USDT — crypto address">
    ```bash theme={null}
    curl -X PUT "$SPENDIN_BASE_URL/v1/refund-destinations" \
      -H "X-API-Key: $SPENDIN_API_KEY" \
      -H "Idempotency-Key: $(uuidgen)" \
      -H "Content-Type: application/json" \
      -d '{
        "currency": "USDT",
        "type": "CRYPTO_ADDRESS",
        "network": "TRON",
        "address": "TXyz1234abcd..."
      }'
    ```

    `network` must be `TRON` or `BSC`. No other chain is accepted.

    <Warning>
      Crypto addresses cannot be verified — chains have no account names, so
      `is_verified` stays `false` and **you own the correctness of the address**.
      A refund sent to a mistyped address is gone. Check it character by
      character, and confirm the network matches the address format.
    </Warning>
  </Tab>

  <Tab title="NGN — bank account">
    ```bash theme={null}
    curl -X PUT "$SPENDIN_BASE_URL/v1/refund-destinations" \
      -H "X-API-Key: $SPENDIN_API_KEY" \
      -H "Idempotency-Key: $(uuidgen)" \
      -H "Content-Type: application/json" \
      -d '{
        "currency": "NGN",
        "type": "BANK_ACCOUNT",
        "bank_code": "058",
        "account_number": "0123456789",
        "bank_name": "Guaranty Trust Bank"
      }'
    ```

    `account_number` is a 10-digit NUBAN. We run name enquiry **before saving**, so
    a typo fails here rather than when a refund is already owed:

    ```json theme={null}
    {
      "error": {
        "code": "BANK_ACCOUNT_VERIFICATION_FAILED",
        "message": "The bank account could not be verified and was not saved.",
        "details": [{ "field": "account_number", "issue": "Account not found" }],
        "request_id": "req_01HX..."
      }
    }
    ```

    The stored `account_name` is the **bank-confirmed** name, not anything you
    typed. `is_verified` comes back `true`.
  </Tab>
</Tabs>

Requires the `refunds:manage` scope.

## What you registered

```bash theme={null}
curl "$SPENDIN_BASE_URL/v1/refund-destinations" \
  -H "X-API-Key: $SPENDIN_API_KEY"
```

```json theme={null}
[
  {
    "id": "e5f6a7b8-...",
    "currency": "USDT",
    "type": "CRYPTO_ADDRESS",
    "network": "TRON",
    "address_masked": "TXyz12...abcd",
    "is_verified": false,
    "is_active": true,
    "created_at": "2026-08-16T09:00:00.000Z",
    "updated_at": "2026-08-16T09:00:00.000Z"
  }
]
```

Account numbers and addresses are **always masked**. PII never leaves whole, not
even back to you — you already hold the values you registered.

## When a refund happens

Exactly one condition: the payout reached `FAILED` **and** a matched settlement
exists. A payout that failed before your money arrived has nothing to return.

```mermaid theme={null}
flowchart LR
  A[payout FAILED] --> B[PENDING]
  B --> C[PROCESSING]
  C --> D[SUCCESSFUL]
  C --> E[FAILED]
```

| Status       | Meaning                                                 |
| ------------ | ------------------------------------------------------- |
| `PENDING`    | Raised, not yet dispatched                              |
| `PROCESSING` | Accepted by the settlement engine                       |
| `SUCCESSFUL` | The money is back with you                              |
| `FAILED`     | Undeliverable after retries — we escalate this manually |

A refund is raised within about a minute of the payout failing. The payout itself
gets `is_refunded: true` and a `refund_reference` at the same moment.

<Note>
  `EXPIRED` payouts never produce a refund. Expiry means the instruction lapsed
  with no money received, so there is nothing to send back.
</Note>

## What you get back

What actually arrived, minus the cost of sending it back out.

```
received_amount      42.32 USDT   ← what we received, including any overpayment
network_fee_amount    1.00 USDT   ← cost of the return transfer
refund_amount        41.32 USDT   ← what you receive
```

The deduction is the network or transfer fee for the rail — a TRC-20 or BEP-20
network fee for crypto, a bank transfer fee for NGN. If that fee is
unconfigured it resolves to zero rather than a guess, so a misconfiguration on our
side can never quietly shortchange you.

Note that `received_amount` is what arrived, not what was expected. Overpay a
payout that later fails and the excess comes back too.

## Reading refunds

```bash theme={null}
curl "$SPENDIN_BASE_URL/v1/refunds?status=SUCCESSFUL&limit=20" \
  -H "X-API-Key: $SPENDIN_API_KEY"
```

```json theme={null}
{
  "data": [
    {
      "id": "f6a7b8c9-...",
      "payout_id": "a3f1c2d4-...",
      "inflow_id": "b7e2d1a0-...",
      "status": "SUCCESSFUL",
      "received_amount": 42.32,
      "received_currency": "USDT",
      "network_fee_amount": 1,
      "network_fee_currency": "USDT",
      "refund_amount": 41.32,
      "refund_currency": "USDT",
      "destination_type": "CRYPTO_ADDRESS",
      "destination_provider_name": "USDT (TRC-20)",
      "destination_masked": "TXyz12...abcd",
      "destination_network": "TRON",
      "processor_reference": "NEONA-RF-00417",
      "attempt_number": 1,
      "dispatched_at": "2026-08-16T15:02:11.000Z",
      "settled_at": "2026-08-16T15:03:40.000Z",
      "created_at": "2026-08-16T15:01:55.000Z"
    }
  ],
  "meta": { "next_cursor": null, "has_more": false, "limit": 20 }
}
```

Every refund names the `payout_id` and `inflow_id` it returns, so you can always
trace money back to the failed payment that produced it.

| Endpoint                              | Returns                                                |
| ------------------------------------- | ------------------------------------------------------ |
| `GET /v1/refunds`                     | Full history, cursor-paginated, filterable by `status` |
| `GET /v1/refunds/{id}`                | A single refund                                        |
| `GET /v1/payouts/{payout_id}/refunds` | Refunds against one payout                             |

All three need `refunds:read`.

## Replacing a destination mid-flight

A refund **snapshots** the destination when it is created. Change your destination
afterwards and an in-flight refund still goes where it was already sent — the
history is not rewritten.

The snapshot fields on the refund (`destination_masked`, `destination_bank_code`,
`destination_network`) are the record of where the money actually went, not where
your current destination points.

## Two failure modes worth handling

<AccordionGroup>
  <Accordion title="You deleted the destination after creating payouts" icon="triangle-exclamation">
    If a payout fails and no destination exists for its settlement currency, **no
    refund is created**. The money is held and escalated to our operations team
    rather than sent somewhere unverified.

    Nothing is lost, but resolution needs a human. Keep a destination registered
    for as long as you have payouts in flight.
  </Accordion>

  <Accordion title="A refund reaches FAILED" icon="circle-exclamation">
    A refund that exhausts its retries stops at `FAILED`. This is your money
    sitting undelivered, so it is an operational escalation on our side, not a
    resting state — but you should alert on it too.

    The usual causes are an address on the wrong chain or a bank account that has
    since been closed. Both need a corrected destination before we can retry.
  </Accordion>
</AccordionGroup>

## Webhooks

Subscribe to these rather than polling:

| Event               | Fires when                                    |
| ------------------- | --------------------------------------------- |
| `payout.refunded`   | A refund was raised against the failed payout |
| `refund.created`    | The refund record exists, status `PENDING`    |
| `refund.processing` | Dispatched to the settlement engine           |
| `refund.successful` | Your money is back                            |
| `refund.failed`     | Undeliverable — needs attention               |

<Warning>
  All four `refund.*` events carry a **payout** snapshot in `data`, with
  `"object": "payout"` — not a refund object. Use `event_type` to tell them apart
  and `data.id` as the payout ID, then call `GET /v1/payouts/{id}/refunds` for the
  refund amounts. See [Webhook events](/reference/events).
</Warning>
