Skip to main content

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.
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.
Design for it explicitly:
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: 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.
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: 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 429s — 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. 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} 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. 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