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

# Get started with transfers

> Move money between two of your own NGN accounts, end to end, in three calls.

Same currency, own accounts — the shortest complete transfer. No FX quote, no route, and it usually settles immediately.

```bash theme={null}
export SAKAWAVE_API_KEY='sk_test_replace_with_your_key'
export SAKAWAVE_BASE_URL='https://sandbox-api.sakawave.example'
```

You need two NGN accounts of your own, both `active` with `transferReady: true`. See [get started with accounts](/services/accounts/get-started) if you do not have them.

## 1. Resolve a destination

A transfer spends a destination, not an account id.

```bash theme={null}
DESTINATION=$(curl --silent --show-error --fail-with-body \
  --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\": \"$TARGET_ACCOUNT_ID\",
    \"currency\": \"NGN\"
  }")

DESTINATION_ID=$(printf '%s' "$DESTINATION" | jq --raw-output '.id')
```

If this returns `404 destination_account_not_found`, the target cannot receive. One code covers every cause, so check the account yourself: `status`, `canCredit`, `currency`, `transferReady`, and whether it is a customer wallet — wallets are excluded from `own_account` destinations and must be paid by tag.

## 2. Preview

```bash theme={null}
curl --request POST \
  --url "$SAKAWAVE_BASE_URL/v1/transfers/preview" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
  --header "Content-Type: application/json" \
  --data "{
    \"sourceAccountId\": \"$SOURCE_ACCOUNT_ID\",
    \"destinationId\": \"$DESTINATION_ID\",
    \"sourceCurrency\": \"NGN\",
    \"destinationCurrency\": \"NGN\",
    \"amountSide\": \"source\",
    \"sourceAmount\": \"2500000\"
  }"
```

`"2500000"` is ₦25,000 — **minor units, as a string**. The response comes back in decimals:

```json theme={null}
{
  "sourceCurrency": "NGN",
  "destinationCurrency": "NGN",
  "sourceAmount": "25000.00",
  "destinationAmount": "25000.00",
  "fee": "50.00",
  "totalDebit": "25050.00",
  "routeId": null,
  "scheme": "internal",
  "fxQuoteId": null,
  "quoteExpiresAt": null
}
```

`totalDebit` is the number to show a payer: the amount plus the fee, and what actually leaves the account.

<Note>
  Preview does not check the balance and reserves nothing. It can succeed and the transfer can still fail with `insufficient_funds`.
</Note>

## 3. Transfer

The same body, plus an `externalReference` you chose and stored **before** sending.

<CodeGroup>
  ```bash cURL theme={null}
  curl --request POST \
    --url "$SAKAWAVE_BASE_URL/v1/transfers" \
    --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
    --header "Content-Type: application/json" \
    --data "{
      \"sourceAccountId\": \"$SOURCE_ACCOUNT_ID\",
      \"destinationId\": \"$DESTINATION_ID\",
      \"sourceCurrency\": \"NGN\",
      \"destinationCurrency\": \"NGN\",
      \"amountSide\": \"source\",
      \"sourceAmount\": \"2500000\",
      \"externalReference\": \"payout_20260814_001\",
      \"narration\": \"Treasury sweep\"
    }"
  ```

  ```ts Node.js theme={null}
  // Persist the reference before the call, so a crash mid-request cannot lose it.
  await recordIntent({ reference: 'payout_20260814_001', amount: '2500000' });

  const response = await fetch(`${process.env.SAKAWAVE_BASE_URL}/v1/transfers`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SAKAWAVE_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      sourceAccountId,
      destinationId,
      sourceCurrency: 'NGN',
      destinationCurrency: 'NGN',
      amountSide: 'source',
      sourceAmount: '2500000',
      externalReference: 'payout_20260814_001',
      narration: 'Treasury sweep',
    }),
  });

  const transfer = await response.json();
  if (!response.ok) {
    // A failed execution still leaves a transfer row holding this reference.
    throw new Error(`${response.status}: ${transfer.code ?? transfer.reason} — ${transfer.message}`);
  }

  console.log(transfer.status, transfer.transferReference, transfer.totalDebit);
  ```
</CodeGroup>

An own-account transfer typically comes back `completed`:

```json theme={null}
{
  "id": "01K2Z3AB4J5K6M7N8PQRSTVWXY",
  "externalReference": "payout_20260814_001",
  "transferReference": "SKWV-TRF-01K2Z3AB4J5K6M7N8PQRSTVWXY",
  "destinationType": "own_account",
  "sourceAmount": "25000.00",
  "fee": "50.00",
  "totalDebit": "25050.00",
  "status": "completed",
  "scheme": "internal",
  "failureCode": null,
  "completedAt": "2026-08-14T09:08:00.000Z"
}
```

An external transfer comes back `pending` instead. That is normal — settlement is asynchronous, and the outcome arrives as a `fiat.withdrawal.completed` or `fiat.withdrawal.failed` webhook.

## 4. Check the ledger

```bash theme={null}
curl --silent \
  --url "$SAKAWAVE_BASE_URL/v1/transactions?pageSize=5" \
  --header "Authorization: Bearer $SAKAWAVE_API_KEY" \
| jq '.items[] | {id, type, amountFormatted, category, status}'
```

The transfer is the instruction; the transaction is the record of what moved.

## Three things that will bite you

1. **A failed create still creates a transfer.** If execution fails, you get an error *and* a transfer row in `failed` status holding your `externalReference`. Retrying with that reference returns the failure, not a new attempt — mint a new reference and resolve a fresh destination.
2. **Destinations are single-use and expire in 24 hours.** Resolve one per attempt. Do not store a `destinationId` on a payout row and reuse it.
3. **Approval, if your business uses it, is a dashboard action.** A `201` with `status: "awaiting_approval"` means the API has done all it can.

## If it failed

| Status | `code` | What to do |
| - | - | - |
| `409` | `insufficient_funds` | The account does not hold `totalDebit`. Fund it, then retry with a **new** reference. |
| `409` | `transfer_source_account_ineligible` | Source is inactive, not debitable, a customer wallet, or in another currency. |
| `409` | `transfer_destination_expired` | Expired or already spent. Resolve a fresh destination. |
| `409` | `same_transfer_account` | Source and destination are the same account. |
| `409` | `transfer_amount_invalid` | You sent the wrong amount for `amountSide`, or both. |
| `409` | `fx_quote_required` | The currencies differ. See [cross-currency transfers](/services/transfers/cross-currency-transfers). |
| `403` | `reason: currency_not_available` | The source account is not NGN and your tier is below 3. No `code` on this one. |
| `422` | `reason: daily_outflow_limit_exceeded` | The tier allowance is spent. `remaining` says how much is left. No `code` on this one. |
| `503` | `provider_unavailable` | The settlement provider was unreachable. |

## Next

* [Cross-currency transfers](/services/transfers/cross-currency-transfers)
* [Use cases](/services/transfers/use-cases) — batches, retries, and reconciliation


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