Postman quickstart — sandbox payout end-to-end
This guide walks through proving the payout API works using Twinqle (the flagship sandbox merchant) + Postman. ~10 minutes start to finish. No real money moves — sandbox uses simulated providers.
By the end you’ll have:
- A working Postman collection with 8 requests
- A successful sandbox payout (
po_xxxwithstatus: paid) - Confidence that auth + idempotency + NUBAN resolve + dispatch all work end-to-end
Prerequisites
Sandbox merchant access
You need a logged-in admin user on Twinqle (or any sandbox merchant). Sign in at the dashboard, switch to Sandbox env via the env switcher pill (top right).
Generate a sandbox API key
Dashboard → API & Webhooks → + Generate new key.
- Pick Sandbox
- Label it (e.g. “Postman PoC”)
- IP allowlist: add your current public IP. Mandatory — without at least
one IP, every request returns
403 ip_not_allowed. Look up your IP at ifconfig.me or click “Add my current IP” in the dialog. - Click Generate — the secret shows ONCE (
sk_test_...). Copy it.
Confirm Twinqle has an NGN wallet
Dashboard → Wallets. You should see an NGN wallet with non-zero balance. If it’s empty, click Simulate inbound funding to credit ₦100,000 (sandbox only).
Postman setup
Create a new Postman environment
Postman → Environments → + New environment → name it Swappr Sandbox. Add three variables:
| Variable | Initial value | Current value |
|---|---|---|
swappr_base | https://api.swappr.me | (same) |
api_key | (paste your sk_test_... here) | (same) |
idempotency_key | (leave blank) | (same — populated by the pre-request script below) |
For local dev with the Cloudflare tunnel: replace swappr_base with
https://swappr-dev.the-technest.dev.
Add a pre-request script for idempotency
Open your Swappr Sandbox environment → Pre-request Script tab → paste:
// Auto-generate a fresh Idempotency-Key for each request that needs one.
// Postman has a built-in `pm.variables.replaceIn('{{$guid}}')` helper.
pm.environment.set('idempotency_key', pm.variables.replaceIn('{{$guid}}'));This rotates the idempotency-key on every send so your retries don’t collide with previous requests. (For a real integration you’d persist the same key across retries of the same logical operation — see Idempotency — but for testing, a fresh UUID per send is what you want.)
Create a new collection
Postman → Collections → + → name it Swappr Sandbox PoC. On the
collection’s Authorization tab, set:
- Type:
Bearer Token - Token:
{{api_key}}
This applies the auth header to every request in the collection — no need to set it per-request.
The 8 requests
Add each request below to your Swappr Sandbox PoC collection. Run them in
order to walk through the full flow.
1. Auth check — list balances
Confirms your key works + IP is allowlisted.
GET {{swappr_base}}/v1/balancesExpected: 200 OK with a list of your wallets. If you get 403 ip_not_allowed, the request IP doesn’t match the allowlist — check your IP
at ifconfig.me + update the key in dashboard.
If you get 401 invalid_api_key, the secret is wrong. Re-paste from your
dashboard.
2. List wallets — find the NGN wallet ID
GET {{swappr_base}}/v1/walletsLook at the response → find the entry with currency: "NGN". Copy its id
(starts with ckwallet_ or similar).
Optional: save the wallet id as an env var. In the response panel → click the “Tests” tab on the request → paste:
const json = pm.response.json();
const ngn = json.data.find((w) => w.currency === 'NGN');
if (ngn) pm.environment.set('wallet_id_ngn', ngn.id);This auto-stamps wallet_id_ngn for use in later requests.
3. Verify a recipient — name enquiry
Look up the bank-of-record name for an account. Useful before creating a payout to confirm the recipient details are correct.
POST {{swappr_base}}/v1/name-enquiry
Idempotency-Key: {{idempotency_key}}
Content-Type: application/json
{
"account_number": "0690000032",
"bank_code": "044"
}Expected: 200 OK with resolved_name (the bank-of-record name from GTBank
in this case — sandbox uses Monnify’s resolver by default).
Account 0690000032 at bank 044 (GTBank) is a known-good sandbox test
account. Other valid 10-digit NUBAN numbers also work; sandbox provider
returns synthetic names.
4. Create a payout
Sandbox payouts complete instantly with simulated provider success.
POST {{swappr_base}}/v1/payouts
Idempotency-Key: {{idempotency_key}}
Content-Type: application/json
{
"amount_minor": "5000",
"currency": "NGN",
"recipient": {
"account_number": "0690000032",
"bank_code": "044"
},
"merchant_reference": "POSTMAN_TEST_001",
"narration": "Postman quickstart test"
}Notice: no recipient.name — sandbox auto-resolves it via NUBAN cascade
(same Monnify resolver as step 3). The response will include the
bank-of-record name.
Expected: 201 Created with:
{
"object": "payout",
"id": "ck...",
"reference": "po_xxx",
"status": "paid",
"currency": "NGN",
"amount_minor": "5000",
"fee_minor": "75",
"tax_minor": "0",
"total_debit_minor": "5075",
"recipient_name": "ADAEZE BLESSING NWAFOR",
"recipient_account": "0690000032",
"recipient_bank_code": "044",
"provider": "monnify",
"provider_ref": "SIM_xxx",
"merchant_reference": "POSTMAN_TEST_001",
"created_at": "2026-05-07T...",
"completed_at": "2026-05-07T..."
}Save the payout id for the next step. Add a Tests script:
const payout = pm.response.json();
pm.environment.set('payout_id', payout.id);
pm.environment.set('payout_reference', payout.reference);
pm.test('payout was paid', () => {
pm.expect(payout.status).to.eql('paid');
});5. Retrieve the payout
Confirms the payout record exists + you can read it back.
GET {{swappr_base}}/v1/payouts/{{payout_reference}}(Both id and reference work in the path — try both.)
Expected: 200 OK with the full payout object including the timeline
timestamps (created_at / queued_at / processing_at / completed_at).
6. Re-query payout status
Forces a status refresh by calling the provider directly. In sandbox this
returns the same paid status (idempotent).
POST {{swappr_base}}/v1/payouts/{{payout_id}}/requeryExpected: 200 OK with the updated payout. metadata.lastRequery carries
the timestamp + reason tag (MRQS = merchant re-query status).
7. Cancel attempt — should fail
Cancellation only works on draft or queued payouts. A paid payout
returns invalid_status — this confirms our error envelope works.
POST {{swappr_base}}/v1/payouts/{{payout_id}}/cancel
Content-Type: application/json
{
"reason": "Testing the cancel error path"
}Expected: 400 Bad Request:
{
"error": {
"type": "invalid_request_error",
"code": "invalid_status",
"message": "Cannot cancel a paid payout."
}
}8. Bulk batch (optional)
Push 3 payouts in a single batch. Same currency + recipient shape as single payouts.
POST {{swappr_base}}/v1/batches
Idempotency-Key: {{idempotency_key}}
Content-Type: application/json
{
"currency": "NGN",
"items": [
{
"amount_minor": "1000",
"recipient": { "account_number": "0690000032", "bank_code": "044" },
"merchant_reference": "POSTMAN_BULK_001"
},
{
"amount_minor": "2000",
"recipient": { "account_number": "0690000032", "bank_code": "044" },
"merchant_reference": "POSTMAN_BULK_002"
},
{
"amount_minor": "3000",
"recipient": { "account_number": "0690000032", "bank_code": "044" },
"merchant_reference": "POSTMAN_BULK_003"
}
]
}Bulk endpoints require payout_bulk_upload permission AND a non-empty IP
allowlist. If your key is Owner-created from step 2, both are satisfied. If
you generated a key for a non-Owner user, dashboard → key row → enable
“Allow bulk” + ensure IPs set.
Expected: 201 Created with the Batch object.
If your dual-control threshold is configured higher than ₦60 (the batch
total), it auto-approves to approved status; otherwise it lands in
awaiting_approval and a teammate must approve via the dashboard.
Negative tests (proving error handling works)
These deliberately trigger errors to verify your error handler will work in production. Add each as a separate Postman request:
N1. Missing required field
POST {{swappr_base}}/v1/payouts
Idempotency-Key: {{idempotency_key}}
{ "currency": "NGN", "recipient": { "account_number": "0690000032", "bank_code": "044" } }→ 400 missing_field (amount_minor is required)
N2. Invalid bank code
POST {{swappr_base}}/v1/payouts
Idempotency-Key: {{idempotency_key}}
{
"amount_minor": "1000",
"currency": "NGN",
"recipient": { "account_number": "0000000000", "bank_code": "999" }
}→ 422 recipient_unresolvable (NUBAN cascade exhausted)
N3. Idempotency-Key replay with different body
Send the same request twice with the SAME Idempotency-Key but DIFFERENT amount.
First request (set idempotency_key to a hardcoded value temporarily):
{ "amount_minor": "1000", "currency": "NGN", "recipient": { "account_number": "0690000032", "bank_code": "044" } }Second request (same Idempotency-Key value):
{ "amount_minor": "9999", "currency": "NGN", "recipient": { "account_number": "0690000032", "bank_code": "044" } }→ 409 idempotency_conflict on the second call (same key, body changed).
Send the SAME body twice with the same key → second call returns the cached 201 response (idempotent replay; safe to retry).
N4. Beneficiary cool-down
Run the original step 4 payout again within 5 minutes:
→ 422 beneficiary_cooldown — same recipient paid recently. Pass
allow_duplicate: true to bypass.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
403 ip_not_allowed | Request IP not in key’s allowlist | Update IP in dashboard or use a static-egress proxy |
401 invalid_api_key | Wrong / revoked key | Re-paste from dashboard |
403 merchant_suspended | Merchant account suspended | Contact support |
recipient_unresolvable on a real account | Sandbox NUBAN resolver couldn’t find the account | Try a different account; sandbox uses a single resolver (default Monnify) |
wallet_not_found | No NGN wallet for this merchant in sandbox env | Check Wallets page; if no wallet shown, contact support |
Payout stuck processing | Sandbox shouldn’t do this — it always returns paid synchronously | Check provider configuration on /admin/providers |
What’s next?
- Authentication details
- Idempotency rules
- Webhooks — set up an endpoint to receive
payout_paidevents - Bulk payouts guide
- Switch from sandbox to live: complete KYC + generate a
sk_live_key + add production IPs to the allowlist + changeswappr_basetohttps://api.swappr.me