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

# Create a transfer

> Spends a destination and moves money. Returns as soon as the transfer has a status, which for most transfers means `pending` rather than `completed` — settlement finishes asynchronously and is reported by webhook.

## Idempotency

`externalReference` is unique within your business and is the idempotency key. Persist it **before** sending the request, and reuse it on every retry of the same intended payment.

Reusing it returns the existing transfer as it stands, without comparing the rest of the payload. Two consequences to design around:

* A different payment sent under a reference you have already used returns the **old** transfer and moves no money. Never reuse a reference for a different payment.
* A transfer that failed keeps its reference. Retrying with the same reference returns the **failed** transfer, not a new attempt. To genuinely retry a failed payment, mint a new reference — and resolve a fresh destination for it.

<Warning>A failed create still creates a transfer. If execution fails — most often `insufficient_funds` — the response is an error, and a transfer row exists in `failed` status holding that `externalReference`.</Warning>

## What comes back

| Status | Meaning |
| - | - |
| `pending` | Accepted and submitted. Settlement is asynchronous; wait for a webhook. |
| `completed` | Already settled, typical for an internal book transfer. |
| `awaiting_approval` | Your business requires a human approval for this transfer. |

<Warning>**A transfer awaiting approval cannot be approved through this API.** Approval and rejection are dashboard actions. If your business has an approval policy switched on, API-initiated transfers stop here until someone approves them.</Warning>

The transient `submitting` status is never published to a webhook, and you will rarely see it in a response.

## Amounts

You send minor units as strings — `"2500000"` — and get back display decimals — `"25000.00"`. `totalDebit` is `sourceAmount` plus `fee`, and is what actually leaves the account.

## Error codes

| Status | `code` | Cause |
| - | - | - |
| `400` | `validation_failed` | A field failed validation, or an unknown field was sent. |
| `409` | `insufficient_funds` | The account does not hold `totalDebit`. A transfer row is created in `failed` status. |
| `409` | `transfer_source_account_ineligible` | The source account is missing, inactive, not debitable, a customer wallet, or in a different currency. |
| `409` | `transfer_destination_expired` | The destination has expired or has already been spent. Resolve a fresh one. |
| `409` | `transfer_destination_unavailable` | The destination was spent between preview and execution. |
| `409` | `same_transfer_account` | Source and destination resolve to the same account. |
| `409` | `accounts_held_separately` | The USDC account cannot convert, or two USDC accounts hold their balances apart. |
| `409` | `transfer_amount_invalid` | The wrong amount was sent for `amountSide`, or both were. |
| `409` | `fx_quote_required` | The currencies differ and no `fxQuoteId` was supplied. |
| `409` | `fx_quote_unavailable` | The quote expired or was spent before the transfer ran. |
| `409` | `fx_quote_mismatch` | The quote does not match this transfer. |
| `409` | `transfer_amount_below_route_minimum` | Below the route minimum, returned in `minimumAmount`. |
| `409` | `transfer_amount_above_route_maximum` | Above the route maximum, returned in `maximumAmount`. |
| `403` | `— (`reason: currency\_not\_available`)` | The source account is not NGN and your tier is below 3. Carries `currentTier` and `requiredTier`, and no `code`. |
| `422` | `— (`reason: daily\_outflow\_limit\_exceeded`)` | The tier daily outflow allowance for this currency is spent. Carries `limit`, `spentToday`, and `remaining` in minor units, and no `code`. |
| `503` | `provider_unavailable` | The settlement provider could not be reached. |
| `401` | `INVALID_API_KEY` | The key is missing, invalid, inactive, expired, from the other environment, or blocked by its IP allowlist. |
| `429` | `api_key_rate_limit_exceeded` | The per-minute limit for this key was exceeded. Transfers, quotes, destinations, and recipients share one 120-per-minute allowance. |


## OpenAPI

````yaml /openapi.json post /v1/transfers
openapi: 3.1.0
info:
  title: Sakawave Business API
  version: 1.0.0-draft
  summary: >-
    Accounts, transfers, FX quotes, and transaction reconciliation for Sakawave
    businesses.
  description: >-
    Generated from the public NestJS controllers and their Zod request schemas.
    Public URLs and policy statements remain draft until the Phase 1 contract
    checklist is approved.
  contact:
    name: Sakawave API Support
    email: api-support@sakawave.example
servers:
  - url: https://sandbox-api.sakawave.example
    description: Sandbox (default)
  - url: https://api.sakawave.example
    description: Live
security:
  - bearerAuth: []
tags:
  - name: Bill payments
    description: Browse the biller catalog, validate a customer, and pay a bill.
  - name: Cards
    description: Issue, fund, and control USD virtual and disposable cards.
  - name: eSIM
    description: >-
      Sell mobile data abroad: browse the catalog, buy an eSIM, and manage it
      once installed.
  - name: Currencies and countries
    description: >-
      Reference data: what may be held, what may be sent where, and which
      jurisdictions are served.
  - name: Customers
    description: Create and manage customers of your business.
  - name: Accounts
    description: >-
      Open business and customer accounts, and issue the destinations that fund
      them.
  - name: Transfer destinations
    description: Resolve temporary destinations and saved recipients.
  - name: FX quotes
    description: Create and retrieve time-limited cross-currency quotes.
  - name: Transfers
    description: Preview, create, and monitor transfers.
  - name: Transactions
    description: Read ledger entries for reconciliation.
paths:
  /v1/transfers:
    post:
      tags:
        - Transfers
      summary: Create a transfer
      description: >-
        Spends a destination and moves money. Returns as soon as the transfer
        has a status, which for most transfers means `pending` rather than
        `completed` — settlement finishes asynchronously and is reported by
        webhook.
      operationId: createTransfer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTransferRequest'
            example:
              sourceAccountId: 01K2Z3AB4D5E6F7G8H9JKMNPQR
              destinationId: 01K2Z3AB4F5G6H7J8KMNPQRSTV
              sourceCurrency: NGN
              destinationCurrency: NGN
              amountSide: source
              sourceAmount: '2500000'
              externalReference: payout_20260814_001
              narration: Sandbox tutorial transfer
      responses:
        '201':
          description: Successful response.
          headers:
            X-Request-ID:
              description: >-
                The request identifier assigned by Sakawave. Include it in
                support requests.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransferResponse'
              example:
                id: 01K2Z3AB4J5K6M7N8PQRSTVWXY
                externalReference: payout_20260814_001
                transferReference: SKWV-TRF-01K2Z3AB4J5K6M7N8PQRSTVWXY
                destinationType: own_account
                sourceAccountId: 01K2Z3AB4D5E6F7G8H9JKMNPQR
                destinationId: 01K2Z3AB4F5G6H7J8KMNPQRSTV
                sourceCurrency: NGN
                destinationCurrency: NGN
                amountSide: source
                sourceAmount: '25000.00'
                destinationAmount: '25000.00'
                fee: '50.00'
                totalDebit: '25050.00'
                routeId: null
                scheme: internal
                fxQuoteId: null
                destination:
                  displayName: Treasury NGN
                  institutionName: null
                  accountNumberLast4: null
                status: completed
                narration: Sandbox tutorial transfer
                failureCode: null
                failureReason: null
                initiatedByUserId: null
                initiatedByApiKeyId: 01K2Z3AB4N5P6Q7R8STVWXYZAB
                approvedByUserId: null
                approvedAt: null
                completedAt: '2026-08-14T09:08:00.000Z'
                createdAt: '2026-08-14T09:07:58.000Z'
                updatedAt: '2026-08-14T09:08:00.000Z'
        '400':
          $ref: '#/components/responses/ValidationError'
        '401':
          $ref: '#/components/responses/AuthenticationError'
        '403':
          $ref: '#/components/responses/ForbiddenError'
        '404':
          $ref: '#/components/responses/NotFoundError'
        '409':
          $ref: '#/components/responses/ConflictError'
        '422':
          $ref: '#/components/responses/UnprocessableError'
        '429':
          $ref: '#/components/responses/RateLimitError'
        '500':
          $ref: '#/components/responses/ServerError'
        '503':
          $ref: '#/components/responses/ProviderUnavailableError'
      security:
        - bearerAuth: []
components:
  schemas:
    CreateTransferRequest:
      type: object
      additionalProperties: false
      properties:
        sourceAccountId:
          $ref: '#/components/schemas/Ulid'
          description: >-
            Must be active, debitable, in sourceCurrency, and not a customer
            wallet.
        destinationId:
          $ref: '#/components/schemas/Ulid'
          description: An unexpired, unspent destination.
        sourceCurrency:
          $ref: '#/components/schemas/TransferSourceCurrency'
        destinationCurrency:
          $ref: '#/components/schemas/DestinationCurrency'
        amountSide:
          type: string
          enum:
            - source
            - destination
        sourceAmount:
          $ref: '#/components/schemas/PositiveMinorUnits'
        destinationAmount:
          $ref: '#/components/schemas/PositiveMinorUnits'
        fxQuoteId:
          $ref: '#/components/schemas/Ulid'
          description: >-
            Required when the currencies differ, and rejected as a mismatch if
            it was quoted for a different account, destination, side, or amount.
        externalReference:
          type: string
          minLength: 1
          maxLength: 120
        narration:
          type: string
          maxLength: 500
      required:
        - sourceAccountId
        - destinationId
        - sourceCurrency
        - destinationCurrency
        - amountSide
        - externalReference
    TransferResponse:
      type: object
      additionalProperties: false
      properties:
        id:
          $ref: '#/components/schemas/Ulid'
        externalReference:
          type: string
        transferReference:
          type: string
        destinationType:
          type: string
          enum:
            - own_account
            - sakawave_tag
            - external
        sourceAccountId:
          $ref: '#/components/schemas/Ulid'
        destinationId:
          $ref: '#/components/schemas/Ulid'
        sourceCurrency:
          $ref: '#/components/schemas/TransferSourceCurrency'
        destinationCurrency:
          $ref: '#/components/schemas/DestinationCurrency'
        amountSide:
          type: string
          enum:
            - source
            - destination
        sourceAmount:
          $ref: '#/components/schemas/DecimalAmount'
        destinationAmount:
          $ref: '#/components/schemas/DecimalAmount'
        fee:
          $ref: '#/components/schemas/DecimalAmount'
        totalDebit:
          $ref: '#/components/schemas/DecimalAmount'
        routeId:
          anyOf:
            - $ref: '#/components/schemas/Ulid'
            - type: 'null'
        scheme:
          type: string
        fxQuoteId:
          anyOf:
            - $ref: '#/components/schemas/Ulid'
            - type: 'null'
        destination:
          anyOf:
            - type: object
              additionalProperties: false
              properties:
                displayName:
                  type: string
                institutionName:
                  anyOf:
                    - type: string
                    - type: 'null'
                accountNumberLast4:
                  anyOf:
                    - type: string
                    - type: 'null'
              required:
                - displayName
                - institutionName
                - accountNumberLast4
            - type: 'null'
        status:
          type: string
          enum:
            - awaiting_approval
            - submitting
            - pending
            - completed
            - failed
            - reversed
            - rejected
        narration:
          anyOf:
            - type: string
            - type: 'null'
        failureCode:
          anyOf:
            - type: string
            - type: 'null'
        failureReason:
          anyOf:
            - type: string
            - type: 'null'
        initiatedByUserId:
          anyOf:
            - $ref: '#/components/schemas/Ulid'
            - type: 'null'
        initiatedByApiKeyId:
          anyOf:
            - $ref: '#/components/schemas/Ulid'
            - type: 'null'
        approvedByUserId:
          anyOf:
            - $ref: '#/components/schemas/Ulid'
            - type: 'null'
        approvedAt:
          anyOf:
            - $ref: '#/components/schemas/IsoTimestamp'
            - type: 'null'
        completedAt:
          anyOf:
            - $ref: '#/components/schemas/IsoTimestamp'
            - type: 'null'
        createdAt:
          $ref: '#/components/schemas/IsoTimestamp'
        updatedAt:
          $ref: '#/components/schemas/IsoTimestamp'
      required:
        - id
        - externalReference
        - transferReference
        - destinationType
        - sourceAccountId
        - destinationId
        - sourceCurrency
        - destinationCurrency
        - amountSide
        - sourceAmount
        - destinationAmount
        - fee
        - totalDebit
        - routeId
        - scheme
        - fxQuoteId
        - destination
        - status
        - narration
        - failureCode
        - failureReason
        - initiatedByUserId
        - initiatedByApiKeyId
        - approvedByUserId
        - approvedAt
        - completedAt
        - createdAt
        - updatedAt
    Ulid:
      type: string
      minLength: 26
      maxLength: 26
      pattern: ^[0-7][0-9A-HJKMNP-TV-Z]{25}$
      example: 01K2Z3AB4C5D6E7F8G9HJKMNPQ
      description: A 26-character ULID.
    TransferSourceCurrency:
      type: string
      enum:
        - NGN
        - USD
        - GBP
        - EUR
        - GHS
        - ZAR
        - UGX
        - CAD
        - MXN
        - AED
        - USDC
    DestinationCurrency:
      type: string
      enum:
        - NGN
        - USD
        - GBP
        - EUR
        - GHS
        - ZAR
        - UGX
        - CAD
        - MXN
        - AED
        - AUD
        - SGD
        - HKD
        - KES
        - TZS
        - BRL
        - CNY
        - XAF
        - PHP
        - USDC
    PositiveMinorUnits:
      type: string
      pattern: ^[1-9]\d*$
      example: '2500000'
      description: A positive integer number of minor units.
    DecimalAmount:
      type: string
      pattern: ^-?\d+(?:\.\d+)?$
      example: '25000.00'
      description: >-
        A decimal display amount returned by transfer APIs. Preserve it as a
        string.
    IsoTimestamp:
      type: string
      format: date-time
      example: '2026-08-14T09:00:00.000Z'
    RequestError:
      anyOf:
        - $ref: '#/components/schemas/ValidationError'
        - $ref: '#/components/schemas/Error'
      description: >-
        Either a field-level validation failure carrying an errors array, or a
        rule the request broke, identified by code.
    Error:
      type: object
      additionalProperties: true
      properties:
        statusCode:
          type: integer
        message:
          type: string
          description: >-
            A sentence suitable for a log or a support conversation, not for a
            user interface.
        code:
          type: string
          description: >-
            The stable machine-readable reason. Branch on this, never on the
            message.
      required:
        - message
        - code
      description: >-
        An error body. Individual errors may add context fields alongside these
        three.
    ForbiddenError:
      type: object
      additionalProperties: false
      properties:
        statusCode:
          const: 403
        message:
          type: string
        code:
          type: string
          enum:
            - kyc_approval_required
            - international_questionnaire_required
            - international_account_approval_required
            - international_customer_wallet_not_supported
          description: >-
            Present on verification, international access, and customer wallet
            refusals. Absent on tier denials, which carry `reason` instead.
        reason:
          type: string
          enum:
            - currency_not_available
            - feature_not_available
          description: Present on tier denials. Absent on every other refusal.
        currency:
          $ref: '#/components/schemas/AssetCode'
        feature:
          type: string
        currentTier:
          type: integer
          enum:
            - 1
            - 2
            - 3
        requiredTier:
          type: integer
          enum:
            - 1
            - 2
            - 3
      required:
        - statusCode
        - message
    UnprocessableError:
      type: object
      additionalProperties: true
      properties:
        statusCode:
          const: 422
        message:
          type: string
        code:
          type: string
          enum:
            - payment_detail_invalid
            - recipient_profile_incomplete
          description: >-
            Present on validation refusals. Absent on tier denials, which carry
            `reason` instead.
        reason:
          type: string
          enum:
            - daily_outflow_limit_exceeded
            - wallet_cap_exceeded
          description: Present on tier denials.
        errors:
          type: array
          items:
            $ref: '#/components/schemas/ValidationFailure'
        currency:
          $ref: '#/components/schemas/AssetCode'
        currentTier:
          type: integer
          enum:
            - 1
            - 2
            - 3
        limit:
          type: integer
          description: The allowance, in minor units.
        spentToday:
          type: integer
          description: Already spent inside the current daily window, in minor units.
        remaining:
          type: integer
          description: What is left of the allowance, in minor units.
        attempted:
          type: integer
          description: What this request asked for, in minor units.
      required:
        - statusCode
        - message
      description: >-
        A refusal the caller can act on. Individual reasons add context fields
        alongside these.
    RateLimitError:
      type: object
      additionalProperties: false
      properties:
        statusCode:
          const: 429
        message:
          type: string
        code:
          const: api_key_rate_limit_exceeded
        retryAfterSeconds:
          type: integer
          minimum: 1
      required:
        - statusCode
        - message
        - code
        - retryAfterSeconds
    ValidationError:
      type: object
      additionalProperties: false
      properties:
        statusCode:
          const: 400
        message:
          type: string
          description: The single failure, or a count when more than one field failed.
        code:
          const: validation_failed
        errors:
          type: array
          items:
            $ref: '#/components/schemas/ValidationFailure'
      required:
        - statusCode
        - message
        - code
        - errors
    AssetCode:
      type: string
      enum:
        - NGN
        - USD
        - GBP
        - EUR
        - GHS
        - ZAR
        - UGX
        - CAD
        - MXN
        - AED
        - BTC
        - ETH
        - USDT
        - USDC
    ValidationFailure:
      type: object
      additionalProperties: false
      properties:
        message:
          type: string
        field:
          type: string
        code:
          type: string
        path:
          type: array
          items:
            anyOf:
              - type: string
              - type: integer
      required:
        - message
        - field
        - code
        - path
  responses:
    ValidationError:
      description: >-
        The request was rejected before it was acted on. Field failures list
        every failing field.
      headers:
        X-Request-ID:
          description: >-
            The request identifier assigned by Sakawave. Include it in support
            requests.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/RequestError'
          example:
            statusCode: 400
            message: Must be a valid identifier.
            code: validation_failed
            errors:
              - message: Must be a valid identifier.
                field: accountId
                code: custom
                path:
                  - accountId
    AuthenticationError:
      description: >-
        The API key is missing, invalid, inactive, expired, for another
        environment, or blocked by its IP allowlist.
      headers:
        X-Request-ID:
          description: >-
            The request identifier assigned by Sakawave. Include it in support
            requests.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            statusCode: 401
            code: INVALID_API_KEY
            message: Invalid or inactive API credentials.
    ForbiddenError:
      description: >-
        Authenticated, but not permitted to hold or use this currency.
        Entitlement refusals carry `code`; tier denials carry `reason`,
        `currentTier`, and `requiredTier` instead.
      headers:
        X-Request-ID:
          description: >-
            The request identifier assigned by Sakawave. Include it in support
            requests.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ForbiddenError'
          example:
            statusCode: 403
            message: >-
              International wallets for your customers are not available yet.
              Your own approval cannot be used for a customer wallet.
            code: international_customer_wallet_not_supported
            currency: USD
    NotFoundError:
      description: The requested resource was not found for this business.
      headers:
        X-Request-ID:
          description: >-
            The request identifier assigned by Sakawave. Include it in support
            requests.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            statusCode: 404
            code: resource_not_found
            message: Resource not found.
    ConflictError:
      description: The request conflicts with the current resource state.
      headers:
        X-Request-ID:
          description: >-
            The request identifier assigned by Sakawave. Include it in support
            requests.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            statusCode: 409
            code: resource_conflict
            message: The request conflicts with the current resource state.
    UnprocessableError:
      description: The request was well formed but cannot be acted on.
      headers:
        X-Request-ID:
          description: >-
            The request identifier assigned by Sakawave. Include it in support
            requests.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/UnprocessableError'
          example:
            statusCode: 422
            message: Payment detail validation failed.
            code: payment_detail_invalid
            errors:
              - message: Account number is required.
                field: accountNumber
                code: required
                path:
                  - fields
                  - accountNumber
    RateLimitError:
      description: The API key rate limit was exceeded.
      headers:
        X-Request-ID:
          description: >-
            The request identifier assigned by Sakawave. Include it in support
            requests.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/RateLimitError'
          example:
            statusCode: 429
            message: Too many API requests. Try again later.
            code: api_key_rate_limit_exceeded
            retryAfterSeconds: 17
    ServerError:
      description: An unexpected server error occurred.
      headers:
        X-Request-ID:
          description: >-
            The request identifier assigned by Sakawave. Include it in support
            requests.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            statusCode: 500
            code: internal_server_error
            message: An unexpected error occurred.
    ProviderUnavailableError:
      description: >-
        A payment provider could not be reached. The request had no effect;
        retry shortly.
      headers:
        X-Request-ID:
          description: >-
            The request identifier assigned by Sakawave. Include it in support
            requests.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            statusCode: 503
            code: provider_unavailable
            message: We could not issue an account number just now. Try again shortly.
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Sakawave API key
      description: >-
        A secret API key, sent as `Authorization: Bearer sk_test_…` in sandbox
        or `Bearer sk_live_…` in live.

````

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