Payouts
Send money to recipients across NGN bank accounts, CAD Interac email IDs, and GBP / USD / EUR bank rails.
The payout object
{
"object": "payout",
"id": "ckxxxxxxxxxxxxxxxxxx",
"reference": "po_xxxxxxxxxxxx",
"status": "paid",
"currency": "NGN",
"amount_minor": "500000",
"fee_minor": "75",
"tax_minor": "0",
"total_debit_minor": "500075",
"recipient_name": "ADAEZE BLESSING NWAFOR",
"recipient_account": "0690000032",
"recipient_bank_code": "044",
"wallet_id": "ckwallet_xxx",
"provider": "<provider_slug>",
"provider_ref": "<provider_ref>",
"nip_reference": "100004250505000123456789",
"merchant_reference": "ORDER_001",
"narration": "Payroll April 2026",
"batch_id": null,
"failure_code": null,
"failure_message": null,
"created_at": "2026-05-05T12:34:50.123Z",
"queued_at": "2026-05-05T12:34:50.456Z",
"processing_at": "2026-05-05T12:34:51.789Z",
"completed_at": "2026-05-05T12:34:56.789Z"
}Status values
| Status | Description |
|---|---|
draft | Created via dual-control flow; awaiting team approval |
queued | Approved/auto-approved; awaiting dispatch |
processing | Dispatched to provider; awaiting confirmation |
paid | Funds reached the recipient |
paid_manual | Manually marked paid by Technest support after evidence review |
failed | Provider returned failed; if applicable, wallet was auto-reversed |
failed_manual | Manually marked failed by Technest support |
reversed | Paid payout reversed (refund issued) |
cancelled | Cancelled before dispatch |
awaiting_admin_review | Held by an internal fraud rule; awaiting Technest review |
Maker-checker rules
Single payouts that exceed your account’s per-transaction dual-control threshold land in draft status awaiting team approval (approve via dashboard or via your internal tooling that calls our dashboard API).
Live-merchant Owner-only self-approve: on live-approved merchants, only the team member assigned the Owner role can approve their own draft payouts. All other roles (Admin, Approver, etc.) drop to maker-checker on live and need a different teammate to approve, even when they hold both payout_create and payout_approve permissions. Sandbox keeps dual-perm self-approve for development velocity.
The Owner role is capped at 3 holders per merchant. Plan your team accordingly — for example, designate 1 Owner for routine approvals and 2 backups.
Email-OTP gate (dashboard only)
The dashboard’s New Payout and Approve flows can be configured to require an email-OTP before submit AND/OR approve. The threshold is configured per (merchant, env, currency) via MerchantLimits.otpRequiredAtAmountMinor in the admin console. Default in live env: OTP required at any amount. Sandbox: bypassed unless requirePayoutOtpInSandbox flag is set.
API integrations bypass the OTP gate entirely — POST /v1/payouts and POST /v1/batches authenticate via Bearer token + IP allowlist + Idempotency-Key, which is the right MFA shape for machine-to-machine. The OTP is a dashboard-only control for human-clicked actions.
Create a payout
POST /v1/payouts
Create a single payout. The recipient block format is currency-specific.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
amount_minor | string (BigInt) | yes | Minor units (kobo for NGN, cents for USD, etc.) |
currency | string | yes | NGN | GBP | USD | EUR | CAD |
wallet_id | string | no | Auto-resolved from currency + env if omitted |
merchant_reference | string | no | Your own ref. Unique per merchant in 30-day window. |
narration | string | no | Description shown on the recipient’s bank statement (NGN). |
recipient | object | yes | Currency-specific. See below. |
sender | object | conditional | Required if your account requires sender info (IMTO compliance). See sender block below. |
sender_customer_id | string | conditional | Alternative to inlining the sender block. If your account has end-user customer records enabled, pass the customer’s id here and Swappr resolves the sender identity from their stored KYC. Mutually exclusive with sender — pass one or the other, not both. |
allow_duplicate | boolean | no | Bypass the beneficiary cool-down for an intentional retry. |
Recipient by currency
NGN
{
"account_number": "0690000032",
"bank_code": "044"
}Name is auto-resolved via NUBAN. Bank code is the 3-digit CBN code (see Banks).
CAD-Interac
{
"email": "recipient@example.com",
"first_name": "Adaeze",
"last_name": "Okonkwo"
}GBP
{
"account_number": "12345678",
"sort_code": "200000",
"name": "John Smith"
}EUR
{
"iban": "DE89370400440532013000",
"bic_code": "COBADEFFXXX",
"name": "Hans Müller"
}USD (ACH — domestic US)
{
"account_number": "1234567890",
"routing_number": "021000089",
"name": "Jane Doe",
"method": "ach"
}USD (SWIFT/wire — international)
{
"account_number": "1234567890",
"swift_code": "CHASUS33XXX",
"name": "Jane Doe",
"bank_name": "JPMorgan Chase Bank",
"method": "wire"
}method defaults to ach for USD when omitted. Pass wire explicitly + supply swift_code + bank_name for international USD wires.
Sender block (compliance — only when required)
Optional for most merchants. Required when your merchant account is configured to capture sender identity on every transaction (e.g. for cross-border AML compliance under CBN IMTO regulations). Your dashboard shows whether your account requires it. If you’re unsure whether your business model needs this, contact support@the-technest.com.
{
"first_name": "Jane",
"last_name": "Carter",
"id_type": "Passport",
"id_number": "A9876543",
"id_expiry": "15082031",
"date_of_birth": "12041992"
}| Field | Notes |
|---|---|
id_type | Strict enum: Passport | Driver's License | National-ID | Residence Card |
id_number | Free-text |
id_expiry | DDMMYYYY |
date_of_birth | DDMMYYYY |
Example
curl https://api.swappr.me/v1/payouts \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount_minor": "500000",
"currency": "NGN",
"recipient": {
"account_number": "0690000032",
"bank_code": "044"
},
"merchant_reference": "ORDER_001",
"narration": "Payroll April 2026"
}'Response
201 Created with the Payout object. Status will typically be paid (sandbox) or queued/processing (live, transitions to paid when the provider confirms).
Errors
| Code | HTTP | Cause |
|---|---|---|
missing_field | 400 | Required body field absent |
invalid_field | 422 | Wrong type / format (e.g. id_expiry not DDMMYYYY) |
wallet_not_found | 422 | wallet_id doesn’t match merchant + currency + env |
unsupported_currency | 422 | Currency not active for this merchant |
recipient_unresolvable | 422 | NUBAN cascade exhausted; account invalid at every provider |
name_mismatch | 422 | (FX rails only) Merchant-supplied name differs from bank-of-record |
beneficiary_cooldown | 422 | Same recipient paid in the last 5 minutes — pass allow_duplicate: true if intentional |
limit_violation | 422 | Per-tx / daily / per-beneficiary cap exceeded; see error.detail.kind |
fraud_rule_blocked | 422 | Internal fraud rule blocked the payout; see error.detail.hits |
merchant_blocked | 422 | Recipient on your merchant blacklist |
globally_blocked | 422 | Recipient on platform-wide blacklist (cannot override) |
sender_info_required | 422 | Account requires sender info; see sender block |
idempotency_conflict | 409 | Same Idempotency-Key was used with a different body |
provider_error | 502 | Downstream rail unreachable; safe to retry with same idempotency key |
List payouts
GET /v1/payouts
Cursor-paginated list. Filter by status, currency, date range.
Query params
| Param | Notes |
|---|---|
limit | 1-100, default 50 |
starting_after | Cursor (last payout id from previous page) |
status | Filter to one status |
currency | Filter to one currency |
created_after | ISO 8601 |
created_before | ISO 8601 |
Response
{
"object": "list",
"has_more": true,
"data": [
{ "object": "payout", "id": "...", ... },
...
]
}Retrieve a payout
GET /v1/payouts/{id}
Accepts the cuid (ckxxx) OR the po_xxx reference.
curl https://api.swappr.me/v1/payouts/po_da06226542dc44a9 \
-H "Authorization: Bearer sk_test_..."Returns the full Payout object including timeline timestamps + provider info + beneficiary link if saved + batch context if part of a bulk batch.
Re-query payout status
POST /v1/payouts/{id}/requery
Forces a status refresh by calling the provider directly. Use this when a payout has been stuck in processing for an unusually long time.
If the provider returns failed, the wallet is auto-reversed (principal + fee + tax credited back) — the response shows the updated payout with status: "failed" and metadata flagging the auto-reverse with reason tag MRQS (merchant re-query status).
curl https://api.swappr.me/v1/payouts/po_xxx/requery \
-X POST \
-H "Authorization: Bearer sk_test_..."Errors
| Code | Meaning |
|---|---|
payout_not_found | Wrong id, wrong env, or not yours |
invalid_status | Payout already in a terminal state — nothing to re-query |
no_provider | Payout was never dispatched (still draft) |
requery_unsupported | Adapter for this provider doesn’t expose re-query (contact support) |
provider_error | Provider returned an error; transient, retry safe |
Cancel a payout
POST /v1/payouts/{id}/cancel
Cancel a payout in draft (no wallet movement) or queued (wallet auto-reversed) status. Cannot cancel processing or terminal-status payouts.
Request
{
"reason": "Customer requested cancellation"
}reason is required, min 3 chars, max 500.
Response
200 OK with the updated Payout object (status will be cancelled).
Errors
| Code | Meaning |
|---|---|
payout_not_found | Wrong id or not yours |
invalid_status | Cannot cancel — payout is processing/paid/failed/etc |
missing_field | reason not provided |