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

# Transfer use cases

> Four flows — a payout batch, retrying safely, watching a transfer to completion, and reconciling against the ledger.

## 1. Run a payout batch

The shape that survives contact with production: one destination per payee per attempt, an idempotency key derived so retries are distinguishable, and pacing built in.

```ts theme={null}
for (const payout of batch.payouts) {
  // A reference that can tell a retry from a repeat.
  const reference = `${batch.id}_${payout.id}_a${payout.attempt}`;
  await recordIntent(payout.id, reference);      // before the call, always

  const destination = await post(`/v1/transfer-destinations/recipients/${payout.recipientId}`, {});

  if (destination.displayName !== payout.expectedAccountName) {
    await flagForReview(payout, destination.displayName);   // verification mismatch
    continue;
  }

  const transfer = await post('/v1/transfers', {
    sourceAccountId: batch.sourceAccountId,
    destinationId: destination.id,
    sourceCurrency: batch.currency,
    destinationCurrency: payout.currency,
    amountSide: 'source',
    sourceAmount: payout.minorUnits,        // string, minor units
    externalReference: reference,
    narration: payout.narration,
  });

  await recordResult(payout.id, transfer.id, transfer.status);
  await pace();   // 120/min covers destinations and transfers together
}
```

Three deliberate choices in there:

* **The destination is resolved inside the loop.** They are single-use and expire; one per attempt is the only correct pattern.
* **The name check happens before the money.** On verified routes `displayName` is the name the bank holds — a mismatch means the account number is wrong or belongs to someone else.
* **The intent is recorded before the call.** If the process dies mid-request, you still know the reference to ask about.

## 2. Retry without paying twice

The rules are unusual enough to be worth stating as code.

```ts theme={null}
async function retry(payout) {
  const existing = await get(`/v1/transfers?search=${payout.lastReference}`);
  const previous = existing.items[0];

  if (!previous) {
    // Never landed. Safe to resend under the same reference.
    return send(payout, payout.lastReference);
  }

  if (['pending', 'completed', 'awaiting_approval'].includes(previous.status)) {
    return previous;   // In flight or done. Do not resend.
  }

  if (['failed', 'rejected'].includes(previous.status)) {
    // A failed transfer keeps its reference: resending it returns the failure.
    // A genuine retry needs a new reference and a fresh destination.
    return send(payout, `${payout.lastReference}_r${payout.attempt + 1}`);
  }
}
```

<Warning>
  Resending the same reference is safe and does nothing new. That is the point — but it also means a failed payment will never retry itself. If your job "retries failures" by resending, it will report success forever while moving no money.
</Warning>

## 3. Watch a transfer to completion

An external transfer returns `pending`. Do not poll it in a loop.

```ts theme={null}
// Webhook receiver — the fast path.
onEvent('fiat.withdrawal.completed', async (data) => {
  const transferId = data.relatedOperation.id;
  const transfer = await get(`/v1/transfers/${transferId}`);   // resource wins
  await settle(transfer);
});

onEvent('fiat.withdrawal.failed', async (data) => {
  const transfer = await get(`/v1/transfers/${data.relatedOperation.id}`);
  await recordFailure(transfer.failureCode, transfer.failureReason);
});
```

```ts theme={null}
// Sweeper — the safety net, because delivery is one attempt with no auto-retry.
const stale = await get('/v1/transfers?status=pending&sort=createdAt&pageSize=100');
for (const transfer of stale.items) {
  if (olderThan(transfer.createdAt, minutes(30))) await reconcile(transfer);
}
```

Both are needed. The webhook gives you latency; the sweeper gives you correctness when a delivery is missed.

## 4. Reconcile against the ledger

A transfer is an instruction. A [transaction](/api-reference/transactions/list-transactions) is what actually moved. Reconcile against the ledger, not against your own record of what you asked for.

```bash theme={null}
# What left this account today, with what it related to.
curl --silent \
  --url "$SAKAWAVE_BASE_URL/v1/transactions?pageSize=100" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
| jq '.items[] | select(.type == "debit") | {id, amountFormatted, category, status, externalReference}'
```

| Question | Where to look |
| - | - |
| Did this payment go out? | `GET /v1/transfers/{transferId}` — `status` and `completedAt` |
| What actually left the account? | `GET /v1/transactions`, `type: debit` |
| Why did it fail? | The transfer's `failureCode` and `failureReason` |
| What did it cost? | The transfer's `fee`, and `totalDebit` for the whole debit |
| Where did it go? | The transfer's `destination` snapshot — kept even if the recipient was deleted |

The snapshot matters: `destination.displayName`, `institutionName`, and `accountNumberLast4` are frozen onto the transfer. Deleting a recipient afterwards does not blank your history.

## 5. Sweep between your own accounts

The simplest transfer there is, and useful as a health check.

```bash theme={null}
DESTINATION=$(curl --silent --request POST \
  --url "$SAKAWAVE_BASE_URL/v1/transfer-destinations" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{\"type\":\"own_account\",\"accountId\":\"$TREASURY_ID\",\"currency\":\"NGN\"}")

curl --request POST \
  --url "$SAKAWAVE_BASE_URL/v1/transfers" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{
    \"sourceAccountId\": \"$OPERATING_ID\",
    \"destinationId\": $(printf '%s' "$DESTINATION" | jq '.id'),
    \"sourceCurrency\": \"NGN\", \"destinationCurrency\": \"NGN\",
    \"amountSide\": \"source\", \"sourceAmount\": \"100000000\",
    \"externalReference\": \"sweep_20260814\"
  }"
```

Own-account transfers settle internally and usually return `completed` immediately, so a daily sweep is also a cheap end-to-end check that your key, accounts, and balances are all healthy.

## What you cannot do here

* **Cancel or amend a transfer.** Once created, it is observed, not steered.
* **Approve or reject one.** Dashboard only.
* **Transfer from a customer wallet.** Wallet accounts are excluded as a source.
* **Filter transfers by account or reference range.** The list takes a fixed set of parameters; use transactions for anything richer.


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