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.
Design for it explicitly:
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.
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 asfiat.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:
- Deduplicate on the
Sakawave-Webhook-Idheader. The body carries no id. - 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.
- The resource wins. When an event and
GET /v1/transfers/{transferId}disagree, the resource is right.
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
-
externalReferencepersisted 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
-
totalDebitshown for approval, notsourceAmount -
awaiting_approvalhandled as a real outcome with a human path - Error handling reads
code ?? reason, and surfacesremainingon limit failures - Batches paced under the shared 120-per-minute allowance
-
fiat.withdrawal.*subscribed, and a reconciliation job against transactions