Welcome
NuevoPay is a payments API for African markets. You collect money from customers over the rails they already use — mobile money, bank transfer, virtual accounts — and pay it out again: to third parties, or to your own crypto treasury as stablecoins. Everything the dashboard can do, the API can do.
Read this guide top to bottom the first time. The early sections build the mental model — how money sits in your account, how a payment moves through its lifecycle, what the API guarantees. The later sections are a per-resource reference you'll come back to. If you've integrated Stripe before, most of this will feel familiar by design.
What you can build
-
Collect payments
Charge customers over mobile money, bank transfer, USSD, or a hosted payment link, across every market we support. -
Virtual accounts
Mint a temporary, payable bank account number for each Nigerian payment, and reconcile deposits automatically. -
Pay out at scale
Disburse to third-party bank accounts and mobile wallets — payroll, supplier payments, marketplace seller payouts — in a single API. -
Sweep to a stablecoin treasury
Convert collected fiat balances into USDC or EURC and withdraw to crypto addresses you control. -
React in real time
Subscribe to webhooks and let your application learn the outcome of every payment the moment it happens.
Get started
Quickstart
Collect your first payment end to end in a few minutes.
Guides
Walk through collecting, paying out, and sweeping funds, step by step.
API reference
Every endpoint, request body, and response
field, generated from
openapi.yaml.
Webhooks
Receive signed events and react to outcomes as they happen.
This guide 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 single source of
truth for the contract.
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.
Your business holds a set of wallets — one balance per currency you operate in. 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 hold: balances are checked and reserved before any outbound transfer leaves. There are three kinds of money movement, and they're separate resources because they behave differently: a collection is money coming in, a payout is money going out to someone else, and a withdrawal is money going out to yourself as stablecoin.
Objects share a predictable shape
Every object we return shares a common envelope. The
id is unique and prefixed by type
(col_ collection, po_
payout, wd_ withdrawal,
wal_ wallet, rcp_
recipient, evt_ event), so you can tell
at a glance what you're holding. created
is a Unix timestamp in seconds. livemode
is true for real money and
false for test data — on every object,
so there's never ambiguity. And
metadata is free-form key/value data you
control: attach your own order or user IDs and
they come straight back on the object and in every
webhook.
We can't tell you the final result of a payment
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. A create request returns an object in a
pending state almost immediately,
and the real outcome arrives later via a webhook.
Design your integration around this from the
start; don't write code that blocks waiting for a
payment to complete inline.
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
This guide walks you through collecting KES 500 from a customer over M-Pesa, end to end, so you can see the shape of a real integration before you go deeper.
Create the collection
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 that
amount is 50000,
not 500 — KES 500.00 expressed
in cents (see Money).
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.)
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": "MPESA", "phone": "254700000000", "customer": { "name": "Jane Doe" }, "reference": "order_1001" }'
{
"id": "col_1Nz9aK2eZvKYlo2C",
"object": "collection",
"status": "pending",
"amount": 50000, "currency": "kes",
"method": "mobile_money",
"operator": "MPESA",
"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. There's nothing else to sign or negotiate — the key is the credential.
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.
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 from 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"
Read the key and base URL from configuration, not a literal in your source. Swapping environments should be a config change, never a code change.
Money and minor units
This trips people up exactly 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 anywhere near money.
Store amounts as integers in your own code too, and format to a decimal string only 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 — your internal order ID plus the action, say. 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.
available.
Why pending exists
When you create a payout for KES 10,000, that amount
moves from available to
pending immediately — reserved, so you
can't spend the same money twice while the transfer
is in flight.
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.
{
"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 afailure_code.
Collections add a couple of states because the
customer is involved — authorizing while
we wait for an OTP, and expired if they
never pay.
You can fetch an object to check its
status, which is 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.
authorizing and expired —
no customer is involved.
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. MPESA,
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. |
pending Created; waiting for customer. authorizing (OTP) waiting for the OTP. processing Authorised; confirming. succeeded Funds received and credited. failed Attempted but did not complete. expired Customer never paid in time.
curl …/collections \ -H "Idempotency-Key: order_1001" \ -d '{ "amount": 50000, "currency": "kes", "method": "mobile_money", "operator": "MPESA", "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
Initiate the charge
As in the Quickstart. Read
auth_model from the response.
Submit the OTP, only if asked
Only if auth_model is
OTP, submit the code to
/authorize (or
/resend_otp if it expired).
Show the instructions
Always surface
instructions — including the
ussd_fallback.
Wait for the webhook
Wait for the
collection.succeeded or
collection.failed event before
granting value.
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. The default window is 30 minutes
(expires_in: 1800); set it shorter for a
checkout, longer for an invoice, and 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, or 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
}
}
Callbacks, explained
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.
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": "MPESA", "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 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.
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.
Every withdrawal requires TOTP step-up in the
dashboard. In v1, withdrawals are stablecoin only
(usdc, eurc).
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.
The 24-hour cooldown
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.
{
"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 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. Events are
retained for 30 days — enough for a reconciliation
backstop, not a permanent archive. Your own database
stays the system of record.
| 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. |
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. Branch on
code, never on
message — the message is human-readable
and may change.
| 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": "MPESA",
"display_name": "MPESA (Safaricom)",
"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. The sandbox is a complete mirror of production — same endpoints, same objects — that moves no real funds.
-
Use test keys.
sk_test_…hits the sandbox. 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 and 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.
BASE=https://api.nuevopay.co/v1 KEY=sk_live_*** # from secrets manager curl $BASE/wallets \ -H "Authorization: Bearer $KEY"