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.

Live: api.nuevopay.co/v1 · sk_live_… Test: api.sandbox.nuevopay.co/v1 · sk_test_… JSON · snake_case · minor units
Looking for the full field-by-field reference?

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.
Payments are asynchronous

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:

  • id is 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.
  • object is the type as a string — handy when handling something generic like a webhook payload.
  • created is a Unix timestamp in seconds (not milliseconds).
  • livemode is true for real money and false for test data — on every object, so there's never ambiguity.
  • metadata is 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.

The object envelope
{
  "id": "col_1Nz9aK2eZvKYlo2C",
  "object": "collection",
  "created": 1764547200,
  "livemode": false,
  "metadata": { "order_id": "1001" }
}
A list envelope
{
  "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.

Don't grant value on the API response

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").

RequestPOST /v1/collections
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"
  }'
Response201 Created
{
  "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.

Authorization header
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.

Worked examples
# 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.

Watch out

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.

A retryable create
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.

WalletGET /v1/wallets/kes
{
  "id": "wal_kes", "object": "wallet",
  "created": 1764547200, "livemode": true,
  "currency": "kes",
  "available_balance": 8450000,
  "pending_balance": 1000000,
  "ledger_balance": 9450000
}
Endpoints
# 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.

Listen, don't poll

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.

Outbound transfer states
pending → processing → succeeded
                    ↘ failed
                      (reserved funds released)
Collection states
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)

FieldReq?Description
amountyesMinor units, e.g. 50000 = KES 500.00.
currencyyesLowercase ISO, e.g. kes.
methodyesmobile_money, virtual_account, bank_transfer, ussd, payment_link.
operatormobile moneye.g. SAFARICOM, MTN, AIRTEL. Discover via reference data.
phonemobile moneyInternational format, e.g. 254700000000.
expires_inNGN onlyVirtual account validity, seconds. Default 1800 (30 min).
customeryes{ name, email?, phone? }.
referenceyesYour unique reference; we use it to reject duplicates.
metadatanoYour key/value data, echoed back everywhere.

Statuses

StatusMeaning
pendingCreated; waiting for the customer.
authorizing(OTP only) waiting for you to submit the customer's OTP.
processingCustomer authorised; confirming with the provider.
succeededFunds received and credited.
failedAttempted but did not complete.
expiredCustomer never paid in the window.
CreatePOST /v1/collections
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 to NONE: 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.

Always surface the USSD fallback

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.

Operator must match the currency

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.

Authorize (OTP only)POST /v1/collections/{id}/authorize
curl …/collections/col_1Nz9/authorize \
  -H "Authorization: Bearer sk_test_123" \
  -d '{ "otp": "123456" }'
Resend OTPPOST /v1/collections/{id}/resend_otp
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.

CreatePOST /v1/collections
curl …/collections -d '{
  "amount": 2500000, "currency": "ngn",
  "method": "virtual_account",
  "expires_in": 1800,
  "customer": { "name": "Tunde A." },
  "reference": "inv_2231"
}'
Response201 Created
{
  "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 webhookcollection.succeeded or collection.failed. This is how your system should learn the result.
The rule that prevents fraud

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.

Create payoutPOST /v1/payouts
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"
  }'
Response202 Accepted
{
  "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.
The most common error

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.

Create withdrawalPOST /v1/withdrawals
curl …/withdrawals \
  -H "Idempotency-Key: sweep_2026_04_25" \
  -d '{
    "amount": 500000, "currency": "kes",
    "asset": "usdc",
    "whitelisted_wallet_id": "wlw_1Nz9d9"
  }'
Response202 Accepted
{
  "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.

Create recipientPOST /v1/recipients
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.

Add walletPOST /v1/whitelisted_wallets
curl …/whitelisted_wallets -d '{
  "asset": "usdc", "chain": "ethereum",
  "address": "0xabc…", "label": "Treasury"
}'
Response201 Created
{
  "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 2xx once 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 id and ignore duplicates.
  • Don't assume ordering. Rely on the object's status in data.object, not on pending arriving before succeeded.
  • We retry failures with exponential backoff, and you can replay deliveries from the dashboard while debugging.

Events

EventFires when
collection.succeededFunds received and credited. Your "money's in" signal.
collection.failed / .expiredPayment failed, or customer never paid.
payout.succeeded / .failedRecipient paid, or payout failed (funds released).
withdrawal.succeeded / .failedFinal outcome of a withdrawal.
whitelisted_wallet.activatedA wallet finished its cooldown and is usable.
wallet.credited / .debitedA wallet balance changed.
Event payload
{
  "id": "evt_1Nz9eR…",
  "object": "event",
  "type": "collection.succeeded",
  "created": 1764547500,
  "livemode": true,
  "data": { "object": {
    "id": "col_1Nz9aK…",
    "object": "collection",
    "status": "succeeded",
    "reference": "order_1001"
  } }
}
Verify (Go)
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.

Rotate without downtime, revoke instantly

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.

List eventsGET /v1/events
curl "…/events?type=collection.succeeded&limit=50" \
  -H "Authorization: Bearer sk_live_123"
Create an API keyPOST /v1/api_keys
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 on message.
  • 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.
typeHTTPWhat to do
invalid_request_error400/402/409Malformed or business-rule violation (insufficient_funds, wallet_not_active…). Fix it; don't blindly retry.
authentication_error401Key missing/invalid/revoked. Check key & environment.
rate_limit_error429Too fast. Back off, retry with delay.
idempotency_error400Key reused with a different payload. Use a fresh key.
api_error500Our side. Retry after a delay; if it persists, contact support with the Request-Id.
A practical pattern

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.

Error402 Payment Required
{
  "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.

Paginate + expand
# 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.

A good pattern

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.

OperatorsGET /v1/reference/operators?currency=kes
{
  "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
}
Other reference endpoints
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 have livemode: false so a mis-set key can't silently send real money.
Sandbox
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/expired collections and failed payouts/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.

Switch to live
BASE=https://api.nuevopay.co/v1
KEY=sk_live_***   # from secrets manager
curl $BASE/wallets \
  -H "Authorization: Bearer $KEY"