NuevoPay
This API lets your business collect money from customers across African markets and pay money out again — to third parties, or to your own crypto treasury as stablecoins. It's built API-first: everything you can do in the dashboard, you can do programmatically.
Read this top to bottom the first time. The early sections build a mental model — how money sits in your account, how a payment moves through its lifecycle, what the API guarantees. Later sections are a per-resource reference. If you've integrated Stripe before, most of this will feel familiar by design.
This page explains the why and the flows. For every endpoint, request body, and response field — with collapsible nested attributes — open api-reference.html, generated from openapi.yaml.
The big picture
Before any code, here's the model the whole API rests on.
Your business has a set of wallets — one per currency you operate in (Kenyan shillings, Ghanaian cedis, and so on). A wallet is simply a balance. Money flows into a wallet when you collect a payment, and out of a wallet when you pay out or withdraw. You can never spend money you don't have in the relevant wallet; balances are checked and reserved before any outbound transfer leaves.
There are three kinds of money movement, and it's worth being clear about the difference now because they're separate resources in the API:
- A collection is money coming in — a customer pays you. Across most corridors this happens over mobile money (M-Pesa, MTN MoMo, Airtel Money), the rail you'll use most. In Nigeria it happens through a temporary bank account number we mint for each payment.
- A payout is money going out to someone else — you disburse funds to a third party's bank account or mobile wallet. Think payroll, supplier payments, marketplace seller payouts.
- A withdrawal is money going out to yourself — converting a wallet balance into stablecoin (USDC, USDT, or EURC) and sending it to one of your own pre-approved crypto wallets. Withdrawals are deliberately restricted: they can only ever go to an address you've whitelisted in advance. This is a treasury operation, not a way to pay arbitrary people, and the API enforces that.
When you ask us to collect or pay out money, we can't tell you the final result in the API response — a mobile money push has to reach the customer's phone, a bank has to accept a transfer, a blockchain transaction has to confirm. So a create request returns an object in a pending state almost immediately, and the real outcome arrives later via a webhook (or by polling). Design your integration around this from the start — don't write code that blocks waiting for a payment to "complete" inline.
How the API works
Every interaction is an HTTP request to a resource, and every response is a JSON object. Resources behave consistently, so once you've learned one you've largely learned them all.
Objects have a predictable shape
Every object we return — a collection, a payout, a wallet — shares a common envelope:
idis unique and prefixed by type, so you can tell at a glance what you're holding:col_collection,po_payout,wd_withdrawal,wal_wallet,rcp_recipient,wlw_whitelisted wallet,we_webhook endpoint,evt_event.objectis the type as a string — handy when handling something generic like a webhook payload.createdis a Unix timestamp in seconds (not milliseconds).livemodeistruefor real money andfalsefor test data — on every object, so there's never ambiguity.metadatais free-form string key/value pairs that you control. Attach your own identifiers (an order ID, a user ID) and they come straight back on the object and in webhooks — the cleanest way to tie our records to yours.
Any endpoint returning multiple items wraps them in a list object and paginates the same way (see Pagination). Failed requests return a typed error you can branch on. And every response carries a Request-Id header — log it, so support can find a request instantly.
{
"id": "col_1Nz9aK2eZvKYlo2C",
"object": "collection",
"created": 1764547200,
"livemode": false,
"metadata": { "order_id": "1001" }
}
{
"object": "list",
"data": [ /* objects, newest first */ ],
"has_more": true,
"url": "/v1/collections"
}
Quickstart
Let's collect KES 500 from a customer over M-Pesa, end to end, so you can see the shape of a real integration.
1 · Create the collection
You tell us the amount, the currency, that you want mobile money, which operator, and the customer's phone number. The Idempotency-Key header makes the request safe to retry. Note amount is 50000, not 500 — KES 500.00 in cents (see Money).
2 · Read the response, act on auth_model
We reply almost immediately with a pending collection. The field to look at is auth_model: it tells you what the customer must do next. Here it's NONE — the customer approves on their phone, so there's nothing more to call. Show instructions.message and wait. (If it were OTP, you'd collect a code and make one more call.)
3 · Receive the outcome via webhook
The customer enters their PIN. Seconds later we credit your KES wallet and send a collection.succeeded event. Your handler verifies it, finds order_1001, and marks it paid. That's the whole loop.
The pending object only means "we've started." A customer can ignore the prompt, fail their PIN, or have insufficient funds. Treat the payment as real only when you receive a collection.succeeded webhook (or fetch the collection and see status: "succeeded").
curl https://api.sandbox.nuevopay.co/v1/collections \ -H "Authorization: Bearer sk_test_123" \ -H "Idempotency-Key: order_1001-attempt-1" \ -H "Content-Type: application/json" \ -d '{ "amount": 50000, "currency": "kes", "method": "mobile_money", "operator": "SAFARICOM", "phone": "254700000000", "customer": { "name": "Jane Doe" }, "reference": "order_1001" }'
{
"id": "col_1Nz9aK2eZvKYlo2C",
"object": "collection",
"status": "pending",
"amount": 50000, "currency": "kes",
"method": "mobile_money",
"operator": "SAFARICOM",
"auth_model": "NONE",
"instructions": {
"message": "A prompt has been sent. Enter your M-PESA PIN.",
"ussd_fallback": "Dial *334# if no prompt arrives."
},
"reference": "order_1001",
"livemode": false
}
Authentication
Authenticate every request by sending a secret API key as a bearer token.
Test vs live
A test key (sk_test_…) works against the sandbox and moves no real money — use it to build and to run automated tests. A live key (sk_live_…) moves real money. The two datasets are completely separate: a collection created with a test key is invisible to a live key, and vice versa. Every object's livemode field tells you which world it belongs to, so you can assert on it in code.
Keep keys secret
A secret key can move money. Treat it like a password: keep it server-side, never embed it in mobile apps or front-end JavaScript, never commit it to source control, and rotate it immediately if it leaks. Keys are shown in full only once, at creation.
Step-up authentication
A few operations are high-risk — initiating a withdrawal, adding a whitelisted wallet, rotating keys, unusually large payouts. In the dashboard these require a TOTP code (an authenticator app) on top of being logged in. Your server-side API integration isn't prompted for TOTP, but your operators are protected this way.
curl https://api.nuevopay.co/v1/wallets \
-H "Authorization: Bearer sk_live_123"
Money and minor units
This trips people up once, so internalise it now: all monetary amounts are integers in the smallest unit of the currency.
KES 500.00 is 50000. NGN 25,000.00 is 2500000. USDC 38.23 is 3823. There are no decimal points and no floating-point numbers anywhere in the API — floating-point arithmetic silently introduces rounding errors, and you never want 0.1 + 0.2 problems near money.
In your own code, store amounts as integers too, and only format to a decimal string for display: divide by 100 for our two-decimal currencies.
Currencies are lowercase ISO codes: ngn, kes, ghs, ugx, tzs, rwf, xof, xaf. Stablecoins use usdc and eurc. Always send the currency alongside the amount — the same integer means very different things in different currencies.
# amount (integer) → display 50000 kes → "KES 500.00" 2500000 ngn → "NGN 25,000.00" 3823 usdc → "USDC 38.23" display = amount / 100
Idempotency
Networks are unreliable. A request times out, you don't know whether it succeeded, so you retry — and now you've paid someone twice. Idempotency keys make that impossible.
On every request that creates something or moves money, send an Idempotency-Key header with a value unique to that logical operation. The first time we see a key, we process the request and store the result. If we see the same key again within 24 hours, we don't reprocess — we return the original, stored response. So you can retry freely: a duplicate request is a no-op that returns the original object.
Use something tied to the operation, not the attempt — e.g. your internal order ID plus the action. A retry of the same operation reuses the same key; a genuinely new operation uses a new one.
If you reuse a key but send a different request body, we return an idempotency_error rather than silently doing the wrong thing. Keep the key and the payload paired. Idempotency matters most on POST /collections, /payouts, and /withdrawals — anywhere a duplicate would move money twice.
curl …/payouts \ -H "Idempotency-Key: order_1001-payout" \ -d '{ … }' # Same key within 24h → original response, # no second payout. Safe to retry.
Wallets and the ledger
Your money lives in wallets — one balance per currency. Behind every wallet is a ledger, and understanding the relationship explains behaviour that surprises people (like why a balance can drop the instant you start a payout, before the money has left).
Three balances, not one
available_balance— what you can actually spend right now.pending_balance— money spoken for but not yet settled, reserved for in-flight payouts and withdrawals.ledger_balance— the total: available plus pending.
Why pending exists
When you create a payout for KES 10,000, we immediately move that amount from available to pending — we reserve it. This stops you spending the same money twice while the payout is in flight. If it succeeds, the reserved amount leaves for good; if it fails, we release the reservation back to available. A failed payout never costs you money.
The ledger is the source of truth
Every movement — a collection crediting you, a fee, a payout reserving then settling — is a double-entry record in an append-only ledger. Records are never edited or deleted; a correction is a new compensating entry. The balance is just a running total. Read the full ledger (GET /v1/wallets/{currency}/ledger) for reconciliation and audit. Wallets are pre-funded — they gain balance when collections succeed, or when your account team records a bank deposit.
{
"id": "wal_kes", "object": "wallet",
"created": 1764547200, "livemode": true,
"currency": "kes",
"available_balance": 8450000,
"pending_balance": 1000000,
"ledger_balance": 9450000
}
# list all balances GET /v1/wallets # single wallet GET /v1/wallets/kes # paginated ledger (audit trail) GET /v1/wallets/kes/ledger?limit=50
The lifecycle of a payment
Because everything is asynchronous, a payment is best understood as a small state machine. Every collection, payout, and withdrawal has a status field and moves through it in one direction.
- pending — we've accepted your request, validated your balance, and reserved the funds. Nothing has left yet.
- processing — we've handed the transfer to the provider and are waiting for confirmation.
- succeeded — the provider confirmed. The money has moved; the reservation is finalised.
- failed — the transfer couldn't complete. We release reserved funds back to available and tell you why in a
failure_code.
Collections add a couple of states because the customer is involved — authorizing while we wait for an OTP, expired if they never pay.
You can fetch an object to check its status, fine for an occasional check. But the right architecture is to register a webhook and react to events as they happen. Use polling only as a reconciliation backstop.
pending → processing → succeeded
↘ failed
(reserved funds released)
pending → authorizing → processing
→ succeeded
→ failed
→ expired (customer never paid)
Collections
A collection represents money coming into one of your wallets. You create one to request payment from a customer; when they pay and the provider confirms, we credit your wallet (minus any collection fee) and the collection reaches succeeded.
The method depends on the country. Mobile money is the dominant rail across East and West Africa and where most of your volume will come from; Nigeria uses temporary virtual bank accounts.
Common fields (POST /v1/collections)
| Field | Req? | Description |
|---|---|---|
amount | yes | Minor units, e.g. 50000 = KES 500.00. |
currency | yes | Lowercase ISO, e.g. kes. |
method | yes | mobile_money, virtual_account, bank_transfer, ussd, payment_link. |
operator | mobile money | e.g. SAFARICOM, MTN, AIRTEL. Discover via reference data. |
phone | mobile money | International format, e.g. 254700000000. |
expires_in | NGN only | Virtual account validity, seconds. Default 1800 (30 min). |
customer | yes | { name, email?, phone? }. |
reference | yes | Your unique reference; we use it to reject duplicates. |
metadata | no | Your key/value data, echoed back everywhere. |
Statuses
| Status | Meaning |
|---|---|
pending | Created; waiting for the customer. |
authorizing | (OTP only) waiting for you to submit the customer's OTP. |
processing | Customer authorised; confirming with the provider. |
succeeded | Funds received and credited. |
failed | Attempted but did not complete. |
expired | Customer never paid in the window. |
curl …/collections \ -H "Idempotency-Key: order_1001" \ -d '{ "amount": 50000, "currency": "kes", "method": "mobile_money", "operator": "SAFARICOM", "phone": "254700000000", "customer": { "name": "Jane Doe" }, "reference": "order_1001" }'
Collecting with mobile money
Mobile money is a "push" flow: you initiate a charge, and the customer approves it on their own phone. The wrinkle is that operators authorise payments differently, so the API tells you which path you're on through the auth_model field in the create response. Always branch on auth_model rather than assuming a flow.
NONE— the customer gets a prompt on their handset (an STK push) and enters their PIN there. You call nothing else; show instructions and wait.PIN— similar toNONE: the customer authorises on the device. No further API call.OTP— the operator texts the customer a one-time code. You must collect it and submit it back to authorise the charge. This adds one step.
The full flow
1. Initiate the charge (as in the Quickstart). Read auth_model from the response. 2. Only if auth_model is OTP, submit the code to /authorize (or /resend_otp if it expired). 3. Always show instructions — including the ussd_fallback. 4. Wait for the collection.succeeded / collection.failed webhook.
Mobile money prompts sometimes don't arrive (network hiccups). The USSD fallback is a code the customer dials (like *334#) to find and approve the pending payment manually. Showing it visibly, every time, noticeably reduces failed collections.
You can't charge an MTN wallet in kes. Fetch the live list from GET /v1/reference/operators?currency=kes rather than hardcoding, because coverage expands over time.
curl …/collections/col_1Nz9/authorize \ -H "Authorization: Bearer sk_test_123" \ -d '{ "otp": "123456" }'
curl -X POST …/collections/col_1Nz9/resend_otp \
-H "Authorization: Bearer sk_test_123"
Collecting in Nigeria with virtual accounts
Nigeria works differently. Instead of pushing a prompt to a phone, you ask us for a temporary virtual account — a real, payable bank account number that exists just for this one payment. You show the customer the account number; they transfer money from their banking app; we detect the transfer, credit your wallet, and the account expires.
About expiry
expires_at is when the account stops accepting payment. Default window is 30 minutes (expires_in: 1800); set it shorter for a checkout, longer for an invoice. Show the customer a countdown. If money arrives after expiry it's handled by reconciliation rather than auto-credited — contact support for late or mismatched payments.
Under- and over-payments
Bank transfers are free-form, so a customer might send a different amount than asked. We record the amount actually received and flag the variance; decide your own policy (accept, refund the difference, follow up) from the webhook data.
curl …/collections -d '{
"amount": 2500000, "currency": "ngn",
"method": "virtual_account",
"expires_in": 1800,
"customer": { "name": "Tunde A." },
"reference": "inv_2231"
}'
{
"id": "col_1Nz9bQ…",
"status": "pending",
"virtual_account": {
"bank_name": "Example Bank",
"account_number": "9912345678",
"account_name": "ACME / inv_2231",
"expires_at": 1764548999
}
}
Understanding callbacks
The word "callback" gets overloaded in payments, and conflating the three meanings is a real source of security bugs. There are three distinct things, and only one of them is proof of payment.
- The customer redirect (
callback_url). Purely about user experience: after a hosted checkout or payment link, the customer's browser is sent to this URL so they land back on your site. Anyone can navigate to it. It tells you nothing trustworthy about whether money moved — use it to show a "thanks, we're confirming your payment" page, never to mark an order paid. - The provider's server-to-server callback (provider → us). Behind the scenes, the operator or bank notifies us of the real outcome on a secure, signed channel. We verify it, match it to the pending collection, and update the ledger. Authoritative — but between the provider and us; you don't handle it directly.
- Our webhook to you (us → you). When the authoritative outcome is known, we send you a signed webhook —
collection.succeededorcollection.failed. This is how your system should learn the result.
Grant value only on a verified webhook, or on a server-side fetch of the collection that shows succeeded. Never on the browser redirect.
Payouts
A payout sends money from one of your wallets to a third party — a supplier, an employee, a marketplace seller. The destination is a bank account or mobile wallet that belongs to someone else. (To move money to your own crypto wallet, that's a withdrawal.) Like collections, payouts are asynchronous: the response comes back pending, the final result arrives by webhook.
You can describe the recipient inline, or reference one you saved with recipient_id.
What happens on the back end
We check your wallet has enough available_balance, reserve the amount plus fee (moving it to pending), and hand the transfer to a provider. It goes pending → processing → succeeded. On failure it goes to failed, we release the reserved funds, and the payout carries a failure_code. A failed payout never costs you anything.
Know the cost before you commit
Payouts carry a fee, and cross-currency payouts involve an FX conversion. Call POST /v1/payouts/quote with the same body to see the fee, FX rate, and total debit without moving money — ideal for a confirmation screen.
curl …/payouts \ -H "Idempotency-Key: payroll_apr_mary" \ -d '{ "amount": 1000000, "currency": "kes", "recipient": { "type": "mobile_money", "currency": "kes", "name": "Mary W.", "details": { "operator": "SAFARICOM", "phone": "254711111111" } }, "reference": "payroll_apr_mary" }'
{
"object": "payout", "status": "pending",
"amount": 1000000, "currency": "kes",
"fee": 1500,
"recipient_id": "rcp_1Nz9c1…",
"livemode": true
}
Withdrawals
A withdrawal moves money out of a wallet to your own stablecoin wallet — you convert a fiat balance into USDC, USDT, or EURC and send it to a crypto address you control. This is how you sweep collected funds into your treasury.
Why it's a separate resource from payouts
- A payout can go to anyone. A withdrawal can go only to an address you have whitelisted in advance. You cannot withdraw to an arbitrary address, ever — the single most important control protecting your funds. Even if your API key leaked, an attacker could only withdraw to addresses you'd already approved.
- Every withdrawal requires TOTP step-up in the dashboard.
- v1 withdrawals are stablecoin only (
usdc,eurc). Default fee 15 bps (0.15%), unless your account has a negotiated rate.
If you reference a wallet that isn't active yet — still inside its 24-hour cooldown — the request is rejected with HTTP 409 and error.code: "wallet_not_active". Check the wallet's active_at before withdrawing. As always, POST /v1/withdrawals/quote previews fees and FX first.
curl …/withdrawals \ -H "Idempotency-Key: sweep_2026_04_25" \ -d '{ "amount": 500000, "currency": "kes", "asset": "usdc", "whitelisted_wallet_id": "wlw_1Nz9d9" }'
{
"object": "withdrawal", "status": "pending",
"amount": 500000, "currency": "kes",
"asset": "usdc", "fee": 750,
"fx": { "rate": "0.0077",
"converted_amount": 3823,
"converted_currency": "usdc" }
}
Recipients
A recipient is a saved third-party payout destination. You don't have to save them — you can pass details inline on each payout — but saving is cleaner when you pay the same people repeatedly (payroll, regular suppliers): create the recipient once, then reference it by recipient_id.
The shape of details depends on type and country: a bank recipient needs a bank code and account number; a mobile_money recipient needs an operator and phone. Use /reference/banks and /reference/operators to validate before saving, so you catch a wrong bank code at recipient-creation time rather than at payout time.
curl …/recipients -d '{
"type": "bank",
"currency": "ghs",
"name": "Kwame B.",
"details": {
"bank_code": "030100",
"account_number": "1234567890"
}
}'
Whitelisted wallets
A whitelisted wallet is one of your own crypto addresses, pre-approved as a destination for withdrawals. The whitelisting model is what makes withdrawals safe — money can only ever flow to addresses you've deliberately registered.
A newly added wallet is not immediately usable. It's created with status: "pending" and an active_at timestamp set 24 hours out. Only once that passes (and status becomes active) can you withdraw to it. This delay is a deliberate security control: if someone compromised your account and tried to add their own address, the 24-hour wait gives you and our monitoring a window to notice and stop it. There is no way to skip it.
Because active_at is always returned, you can show users exactly when a wallet unlocks ("Active in 23h 41m"). When it activates, we send a whitelisted_wallet.activated webhook so you update without polling. Adding a wallet requires TOTP step-up.
curl …/whitelisted_wallets -d '{
"asset": "usdc", "chain": "ethereum",
"address": "0xabc…", "label": "Treasury"
}'
{
"object": "whitelisted_wallet",
"asset": "usdc", "chain": "ethereum",
"status": "pending",
"active_at": 1764633800
}
Webhooks
Webhooks are how your application finds out that something happened — a collection succeeded, a payout failed, a wallet activated. Because the API is asynchronous, webhooks are not optional for a serious integration; they're the primary way you learn outcomes. (Poll as a backstop, but don't build your core flow on it.)
Register endpoint URLs (POST /v1/webhook_endpoints), choosing which events you want. We POST a JSON event to each URL when a matching event occurs. On creation you get a signing secret — store it; you'll need it to verify deliveries. Every event has the same envelope: type tells you what happened; data.object is the full, current object.
Verifying signatures
Anyone can POST to your URL, so prove each delivery came from us before trusting it. Every request includes Webhook-Signature and Webhook-Timestamp headers. Take the raw body exactly as received, concatenate "{timestamp}.{body}", compute HMAC-SHA256 with your signing secret, and compare with a constant-time check. Reject if it doesn't match, or if the timestamp is older than a few minutes (replay protection).
Best practices
- Respond fast, then work. Acknowledge with a
2xxonce you've verified and stored the event; do heavy lifting in a background job. - Be idempotent. Delivery is at-least-once, so you'll occasionally get the same event twice. Key processing on the event
idand ignore duplicates. - Don't assume ordering. Rely on the object's
statusindata.object, not onpendingarriving beforesucceeded. - We retry failures with exponential backoff, and you can replay deliveries from the dashboard while debugging.
Events
| Event | Fires when |
|---|---|
collection.succeeded | Funds received and credited. Your "money's in" signal. |
collection.failed / .expired | Payment failed, or customer never paid. |
payout.succeeded / .failed | Recipient paid, or payout failed (funds released). |
withdrawal.succeeded / .failed | Final outcome of a withdrawal. |
whitelisted_wallet.activated | A wallet finished its cooldown and is usable. |
wallet.credited / .debited | A wallet balance changed. |
{
"id": "evt_1Nz9eR…",
"object": "event",
"type": "collection.succeeded",
"created": 1764547500,
"livemode": true,
"data": { "object": {
"id": "col_1Nz9aK…",
"object": "collection",
"status": "succeeded",
"reference": "order_1001"
} }
}
func verifyWebhook(secret, sig, ts string, body []byte) bool { // Reject stale deliveries (replay). n, err := strconv.ParseInt(ts, 10, 64) if err != nil || time.Since(time.Unix(n,0)) > 5*time.Minute { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + "." + string(body))) want := hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(want), []byte(sig)) }
Events and API keys
Events
Every state change on your account is recorded as an event (evt_…). Webhooks are simply events pushed to your endpoints — and the same events are queryable, which matters when a delivery is missed or you need to backfill. List with GET /v1/events (filter by type), retrieve one with GET /v1/events/{id}.
An event has the same shape you receive in a webhook (id, type, data.object). Events are retained for 30 days — enough for a reconciliation backstop, not a permanent archive. Your own database stays the system of record.
Managing API keys programmatically
You can create, rotate, and revoke secret keys over the API as well as in the dashboard. The full secret is returned only at creation and rotation — store it then, it can't be retrieved again.
POST /v1/api_keys/{id}/rotate issues a new secret and returns it once; pass grace_period_seconds to keep the old one valid while you roll out the new value. DELETE /v1/api_keys/{id} revokes immediately. Webhook signing secrets rotate the same way via /v1/webhook_endpoints/{id}/rotate_secret.
curl "…/events?type=collection.succeeded&limit=50" \
-H "Authorization: Bearer sk_live_123"
curl …/api_keys -d '{
"name": "billing-service",
"livemode": true,
"scopes": ["read_only"]
}'
# → 201, secret returned ONCE
{ "id": "key_1Nz9fX…", "object": "api_key",
"prefix": "sk_live_…f3a2",
"secret": "sk_live_51H…",
"status": "active" }
Errors and how to handle them
When a request fails, we return an appropriate HTTP status and a JSON body with a structured error object. The goal: your code branches on a stable machine value, while a human reading logs still gets a clear explanation.
type— the broad category. Use it to decide strategy: retry, fix the request, or alert a human.code— the specific, stable reason. Branch on this, never onmessage.message— human-readable, may change; for logs, not logic.param— points at the offending field when one's to blame.doc_url— a fuller explanation.
| type | HTTP | What to do |
|---|---|---|
invalid_request_error | 400/402/409 | Malformed or business-rule violation (insufficient_funds, wallet_not_active…). Fix it; don't blindly retry. |
authentication_error | 401 | Key missing/invalid/revoked. Check key & environment. |
rate_limit_error | 429 | Too fast. Back off, retry with delay. |
idempotency_error | 400 | Key reused with a different payload. Use a fresh key. |
api_error | 500 | Our side. Retry after a delay; if it persists, contact support with the Request-Id. |
Treat rate_limit_error and api_error as transient — safe to retry with backoff (your idempotency key keeps retries safe). Treat invalid_request_error and authentication_error as terminal — retrying won't help; fix the cause.
{
"error": {
"type": "invalid_request_error",
"code": "insufficient_funds",
"message": "Wallet kes has insufficient balance.",
"param": "amount",
"doc_url": "https://docs.nuevopay.co/errors/…"
}
}
Pagination and expanding objects
List endpoints return a list object and never dump your entire history at once. Control the page with limit (default 25, max 100). To walk pages, use cursor pagination: pass the id of the last object you saw as starting_after for the next page. Cursors (not numeric offsets) keep pages stable even as new objects are created while you paginate. Keep going until has_more is false; ending_before paginates the other way.
By default, related objects are represented by their id to keep responses small. When you need the full related object inline, ask with expand[] — saving a second round-trip.
# first page GET /v1/payouts?limit=50 # next page: start after the last id GET /v1/payouts?limit=50 &starting_after=po_1Nz9cD… # embed the full recipient object GET /v1/payouts/po_1Nz9cD… ?expand[]=recipient
Reference data
Which countries, currencies, payment methods, operators, and banks we support changes over time as we add providers. Rather than hardcoding these, fetch them at runtime from the reference endpoints — they're cacheable list objects.
Each operator entry tells you not just its code and name but which auth_models it uses and its USSD fallback — exactly what you need to drive the mobile money flow.
When a customer chooses how to pay, populate your operator dropdown from /reference/operators filtered to their currency. You'll never offer an operator we can't process, and new operators appear automatically without a code change.
{
"object": "list",
"data": [{
"object": "operator",
"code": "SAFARICOM",
"display_name": "Safaricom (M-Pesa)",
"currency": "kes", "country": "KE",
"auth_models": ["NONE"],
"ussd_fallback": "*334#", "enabled": true
}],
"has_more": false
}
GET /v1/reference/countries GET /v1/reference/payment_methods?country=GH GET /v1/reference/banks?country=NG
Testing
Build and test entirely in the sandbox before you touch live money.
- Use test keys.
sk_test_…hits the sandbox and moves no real money. Test and live data are completely separate. - Simulate every path. The sandbox provides test phone numbers and accounts that deterministically trigger each
auth_model(NONE / PIN / OTP) and each outcome (success, failure, expiry). Most integration bugs hide in the failure paths. - Test webhooks end to end. Point a sandbox endpoint at staging (or a tunnel to your laptop) and confirm you verify signatures, handle duplicates, and update records. Replay deliveries from the dashboard.
- Assert on
livemode. In tests, assert objects havelivemode: falseso a mis-set key can't silently send real money.
BASE=https://api.sandbox.nuevopay.co/v1
KEY=sk_test_123
curl $BASE/wallets \
-H "Authorization: Bearer $KEY"
Going live
A short checklist before flipping to production:
- Swap keys and base URL via configuration — never hardcode keys.
- Lock down your keys. Live keys in server-side secrets only, out of source control, with a rotation plan.
- Verify webhooks in production. Register your live endpoint, confirm signature verification against the live signing secret, idempotent handlers, fast responses.
- Handle failure states — do something sensible for
failed/expiredcollections andfailedpayouts/withdrawals, not just the happy path. - Use idempotency keys everywhere money moves.
- Whitelist treasury wallets early — remember the 24-hour cooldown; add destinations before you need them.
- Add a reconciliation backstop — a periodic job that fetches recent objects and compares against your records catches anything a missed webhook left behind.
Companion artifacts: openapi.yaml (machine-readable contract, single source of truth), api-reference.html (generated field-by-field reference), DEVELOPER_GUIDE.md (this guide in Markdown), and PRD.md (product requirements). Run make docs to regenerate the reference. Hostnames and IDs here are illustrative placeholders.
BASE=https://api.nuevopay.co/v1 KEY=sk_live_*** # from secrets manager curl $BASE/wallets \ -H "Authorization: Bearer $KEY"