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

# Transfers setup and configuration

> Approval policy, tier limits, one shared rate limit, and the webhooks that tell you what happened.

## Credentials

API key only. The transfer records who initiated it — `initiatedByApiKeyId` for an API transfer, `initiatedByUserId` for a dashboard one — so the two paths stay distinguishable in your history.

## Approval policy

Your business can require transfers to be approved by a person. The policy has three modes — off, all transfers, or above a threshold — and it is configured in the dashboard, not through this API.

When it applies, `POST /v1/transfers` returns `201` with `status: "awaiting_approval"` and stops there.

<Warning>
  **There is no approve or reject endpoint on the Business API.** Both are dashboard actions. An API-only integration cannot complete a transfer that needs approval, and nothing in the API will tell you the policy is on until a transfer stops.
</Warning>

Design for it explicitly:

```ts theme={null}
const transfer = await post('/v1/transfers', payload);

if (transfer.status === 'awaiting_approval') {
  // The API has done all it can. Someone has to act in the dashboard.
  await notifyApprovers(transfer);
  await markPendingHumanApproval(transfer.id);
}
```

Subscribing to the withdrawal webhooks is how you learn the outcome, since you cannot drive it.

## Tier limits

Two limits apply beyond the balance, both from your business's tier, and both counted per currency:

| Limit | Failure |
| - | - |
| Daily outflow allowance | `422` with `reason: daily_outflow_limit_exceeded` |
| Wallet balance cap (on the receiving side) | `422` with `reason: wallet_cap_exceeded` |

Neither carries a `code`. They carry `reason`, `limit`, `spentToday`, `remaining`, and `attempted` — all in minor units — so a good error message can tell the user exactly how much headroom is left.

```ts theme={null}
if (error.status === 422 && error.body.reason === 'daily_outflow_limit_exceeded') {
  const remaining = BigInt(error.body.remaining);
  throw new DailyLimitReached(remaining);   // "₦12,500 left today"
}
```

Before either limit, the source account's currency is checked against your tier. A non-NGN account sends money only at tier 3, whatever tier it was opened at. Below that, the transfer is refused with `403` carrying `reason: currency_not_available`, `currentTier`, and `requiredTier`.

The daily window is measured in Africa/Lagos time, not UTC.

## Rate limits

Every operation on the transfers controller shares **120 requests per minute**: transfers, previews, FX quotes, destinations, recipients, and payment details together.

A payout run therefore costs more than it looks:

| Per payee | Calls |
| - | -: |
| Destination + transfer | 2 |
| Destination + preview + transfer | 3 |
| Quote + destination + preview + transfer | 4 |

At 120 per minute, a 500-payee run using three calls each needs about twelve and a half minutes of steady pacing. Pace it deliberately rather than absorbing `429`s — a retry storm against a money-moving endpoint is the worst place to discover your backoff logic.

## Webhooks

Transfers arrive as **`fiat.withdrawal.*`** events. There are no `transfer.*` events.

| Event | Meaning |
| - | - |
| `fiat.withdrawal.created` | Accepted |
| `fiat.withdrawal.completed` | Settled |
| `fiat.withdrawal.failed` | Terminal failure |

The payload is the ledger transaction; the transfer sits inside `relatedOperation` with its reference, destination summary, amounts, and failure fields.

Three rules for the receiver:

1. **Deduplicate on the `Sakawave-Webhook-Id` header.** The body carries no id.
2. **Do not trust ordering.** A resend can deliver an older event after a newer one. Apply a change only if it is valid for the state you hold.
3. **The resource wins.** When an event and [`GET /v1/transfers/{transferId}`](/api-reference/transfers/get-a-transfer) disagree, the resource is right.

Delivery is a single attempt with manual resend and no automatic retry, so a scheduled reconciliation against transactions is not optional for money movement.

## Idempotency

`externalReference` is the key. Choose it from something stable in your own system, persist it before the call, and reuse it on every retry of the same intended payment.

| Situation | Result |
| - | - |
| Same reference, same payment, retried | Returns the existing transfer. Safe. |
| Same reference, **different** payment | Returns the **old** transfer. Nothing moves. |
| Same reference, previous attempt failed | Returns the **failed** transfer. Not a new attempt. |

The last row is the one that changes your design: to genuinely retry a failed payment you need a new reference *and* a fresh destination. Derive references so that a retry can be distinguished from a repeat — `payout_{id}_attempt_{n}` rather than `payout_{id}`.

## Configuration checklist

* [ ] `externalReference` persisted before the request, and derived to allow a distinct retry
* [ ] A fresh destination resolved per attempt, never stored and reused
* [ ] Minor units sent, decimal strings expected back, and never confused
* [ ] `totalDebit` shown for approval, not `sourceAmount`
* [ ] `awaiting_approval` handled as a real outcome with a human path
* [ ] Error handling reads `code ?? reason`, and surfaces `remaining` on limit failures
* [ ] Batches paced under the shared 120-per-minute allowance
* [ ] `fiat.withdrawal.*` subscribed, and a reconciliation job against transactions


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