Session expires in 2:00
Introduction 1 / 12
NexaPay API v2

Build mobile money into your app — fast.

A single API to accept and send payments across Zambia, Zimbabwe, Botswana, and Namibia. Integrate in minutes, scale across borders.

4
Countries
9+
Networks
v2
API
Base URL — API v2
All API requests:
Sandbox:
HTTPS only — HTTP is rejected with 301.

What is NexaPay?

NexaPay is a unified mobile money API built by Elicate Technologies Limited that lets developers charge customers and send payouts across Southern Africa — without separate integrations for each telco. One API key, one SDK, four countries.

Supported Countries

Zambia
ZMW
MTN · Airtel · ZAMTEL
Zimbabwe
ZiG / USD
EcoCash · OneMoney
Botswana
BWP
Orange · Mascom MyZaka
Namibia
NAD
MTC Money · FNB eWallet

Response Format

// Success
{ "status": "success", "data": { "transaction_id": "txn_01J...", "status": "pending" } }
// Error
{ "status": "error", "message": "Invalid phone", "error_code": "INVALID_PHONE", "errors": {} }
Never expose your Secret Key
Your secret key (npay_live_sec_…) must be server-side only. Your public key (npay_live_pub_…) is safe for the Checkout SDK frontend.

How It Works

1
Your server sends a charge request
POST to /v2/payments/charge with the customer's phone number, amount, currency, and the mobile network. NexaPay validates the request and returns a transaction_id immediately.
2
Customer receives a USSD prompt
NexaPay contacts the telco's mobile money gateway. The customer sees a USSD or push prompt on their phone asking them to approve or decline the payment.
3
Telco settles & NexaPay fires a webhook
On approval, the telco debits the customer's wallet and credits yours. NexaPay signs and delivers a payin.success webhook to your endpoint within seconds.
4
You verify & fulfill
Your server verifies the X-NexaPay-Signature HMAC, then fulfills the order (issue ticket, activate subscription, ship goods). Do not rely on client-side callbacks alone.

Environments

EnvironmentBase URLKey prefixReal money?
Livenpay_live_sec_ / npay_live_pub_Yes — real transactions
Sandboxnpay_test_sec_ / npay_test_pub_No — simulated only

Supported Operations by Country

FeatureZM ZambiaZW ZimbabweBW BotswanaNA Namibia
Charge (collect)
Payout (disburse)
Payment Links
Checkout SDK
Batch payouts
USD collection
Quick Start

Up and running in 5 minutes

Accept your first mobile money payment in three steps — all 4 countries, all networks.

Step-by-Step

1
Create your API keys
Sign up at nexapay.net and copy your npay_test_sec_… secret key and npay_test_pub_… public key. Store the secret in your environment — never hardcode it.
2
Initiate a charge
POST to /v2/payments/charge with the customer's phone number, amount, currency, and network. NexaPay delivers a payment prompt to the customer's phone.
3
Receive the webhook
NexaPay POSTs a signed webhook when the customer approves or declines. Verify X-NexaPay-Signature and fulfill the order.

Charge — All 4 Countries

// Zambia — ZMW · MTN | AIRTEL | ZAMTEL
const res = await fetch('https://api.nexapay.net/v2/payments/charge', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${process.env.NEXAPAY_SK}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    phone_number: '260971234567', // MTN 096x/076x · Airtel 097x/077x · Zamtel 095x
    amount: 50000, // 500.00 ZMW (in ngwee)
    currency: 'ZMW', network: 'MTN',
    reference: 'ZM-ORD-001', description: 'Order #001'
  })
});
const { data } = await res.json(); // data.transaction_id, data.status
# Zambia — ZMW · AIRTEL
import requests, os
r = requests.post('https://api.nexapay.net/v2/payments/charge',
  headers={'Authorization':f"Bearer {os.environ['NEXAPAY_SK']}"},
  json={'phone_number':'260977654321','amount':50000,
       'currency':'ZMW','network':'AIRTEL','reference':'ZM-ORD-001'})
print(r.json()['data']['transaction_id'])
// Zambia — ZMW · ZAMTEL
$r = json_decode(file_get_contents('https://api.nexapay.net/v2/payments/charge', false,
  stream_context_create(['http'=>['method'=>'POST',
    'header'=>"Authorization: Bearer {$sk}\r\nContent-Type: application/json\r\n",
    'content'=>json_encode(['phone_number'=>'260955123456',
      'amount'=>50000,'currency'=>'ZMW','network'=>'ZAMTEL','reference'=>'ZM-ORD-001'])]]])), true);
curl -X POST https://api.nexapay.net/v2/payments/charge \
  -H "Authorization: Bearer npay_test_sec_..." \
  -H "Content-Type: application/json" \
  -d '{"phone_number":"260971234567","amount":50000,"currency":"ZMW","network":"MTN","reference":"ZM-ORD-001"}'
// Zimbabwe — ZiG · ECOCASH | ONEMONEY · also accepts USD
const res = await fetch('https://api.nexapay.net/v2/payments/charge', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${process.env.NEXAPAY_SK}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    phone_number: '263771234567', // EcoCash 077x · OneMoney 071x
    amount: 2000, // 20.00 ZiG
    currency: 'ZiG', network: 'ECOCASH',
    reference: 'ZW-ORD-001', description: 'Electricity token'
  })
});
# Zimbabwe — USD · ONEMONEY
r = requests.post('https://api.nexapay.net/v2/payments/charge',
  headers={'Authorization':f"Bearer {sk}"},
  json={'phone_number':'263711234567','amount':500,
       'currency':'USD','network':'ONEMONEY','reference':'ZW-ORD-002'})
curl -X POST https://api.nexapay.net/v2/payments/charge \
  -H "Authorization: Bearer npay_test_sec_..." \
  -d '{"phone_number":"263771234567","amount":2000,"currency":"ZiG","network":"ECOCASH","reference":"ZW-ORD-001"}'
// Botswana — BWP · ORANGE | MASCOM
const res = await fetch('https://api.nexapay.net/v2/payments/charge', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${process.env.NEXAPAY_SK}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    phone_number: '26774123456', // Orange 074x · Mascom 071x
    amount: 8500, // 85.00 BWP
    currency: 'BWP', network: 'ORANGE',
    reference: 'BW-ORD-001'
  })
});
# Botswana — BWP · MASCOM MyZaka
r = requests.post('https://api.nexapay.net/v2/payments/charge',
  headers={'Authorization':f"Bearer {sk}"},
  json={'phone_number':'26771123456','amount':5000,
       'currency':'BWP','network':'MASCOM','reference':'BW-ORD-002'})
curl -X POST https://api.nexapay.net/v2/payments/charge \
  -H "Authorization: Bearer npay_test_sec_..." \
  -d '{"phone_number":"26774123456","amount":8500,"currency":"BWP","network":"ORANGE","reference":"BW-ORD-001"}'
// Namibia — NAD · MTC | FNB
const res = await fetch('https://api.nexapay.net/v2/payments/charge', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${process.env.NEXAPAY_SK}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    phone_number: '264811234567', // MTC 081x · FNB eWallet 060x
    amount: 35000, // 350.00 NAD
    currency: 'NAD', network: 'MTC',
    reference: 'NA-ORD-001'
  })
});
# Namibia — NAD · FNB eWallet
r = requests.post('https://api.nexapay.net/v2/payments/charge',
  headers={'Authorization':f"Bearer {sk}"},
  json={'phone_number':'264601234567','amount':20000,
       'currency':'NAD','network':'FNB','reference':'NA-ORD-002'})
curl -X POST https://api.nexapay.net/v2/payments/charge \
  -H "Authorization: Bearer npay_test_sec_..." \
  -d '{"phone_number":"264811234567","amount":35000,"currency":"NAD","network":"MTC","reference":"NA-ORD-001"}'
What happens next
The customer gets a USSD / push prompt on their phone. On approval NexaPay fires payin.success to your webhook. On decline or timeout you receive payin.failed. Always wait for the webhook — never rely on polling.

Install the SDK

Node.js / npm
npm install nexapay-node
Python / pip
pip install nexapay
PHP / composer
composer require elicate/nexapay
Checkout JS (CDN)
js.nexapay.net/v2/checkout.js

Testing in Sandbox

Use npay_test_sec_… keys with the sandbox base URL https://sandbox.nexapay.net/v2. No real money moves. Use the test phone numbers below to simulate different outcomes.

Phone numberNetworkSimulated outcome
260970000001MTN Zambia payin.success after ~5 seconds
260970000002MTN Zambia payin.failed — CUSTOMER_DECLINED
260970000003MTN Zambia payin.failed — TIMEOUT (90 s)
260970000004MTN Zambia payin.failed — INSUFFICIENT_FUNDS
260970000009MTN Zambia 500 — INTERNAL_ERROR (for error handling tests)
263770000001EcoCash Zimbabwe payin.success
26774000001Orange Botswana payin.success
264810000001MTC Namibia payin.success

Testing Webhooks Locally

During development, expose your local server using a tunnel so NexaPay can reach it. Use ngrok, Cloudflare Tunnel, or the NexaPay webhook test command from the sandbox. Register the tunnel URL as your webhook endpoint in the dashboard, then run your test charge.

# 1 — Expose local server
ngrok http 3000

# 2 — Register the tunnel URL as your webhook
curl -X POST https://sandbox.nexapay.net/v2/webhooks \
  -H "Authorization: Bearer npay_test_sec_..." \
  -d '{"url":"https://abc123.ngrok.io/webhooks/nexapay","events":["payin.success","payin.failed"]}'

# 3 — Run a test charge
curl -X POST https://sandbox.nexapay.net/v2/payments/charge \
  -H "Authorization: Bearer npay_test_sec_..." \
  -d '{"phone_number":"260970000001","amount":50000,"currency":"ZMW","network":"MTN","reference":"TEST-001"}'
Authentication

Secure your API requests

Bearer token auth on every request. Two key types — secret for servers, public for clients.

Security Warning
Never commit your secret key to version control or expose it in client-side code. Use process.env.NEXAPAY_SK (Node), os.environ['NEXAPAY_SK'] (Python), or getenv('NEXAPAY_SK') (PHP).
Secret Key
npay_live_sec_…
Server-side only. Full API access.
Public Key
npay_live_pub_…
Frontend safe. Checkout SDK only.

Adding the Authorization Header

const headers = {
  'Authorization': `Bearer ${process.env.NEXAPAY_SK}`,
  'Content-Type':  'application/json'
};
const res = await fetch('https://api.nexapay.net/v2/...', { headers });
import os, requests
session = requests.Session()
session.headers.update({'Authorization':f"Bearer {os.environ['NEXAPAY_SK']}",'Content-Type':'application/json'})
$headers = ['Authorization: Bearer '.getenv('NEXAPAY_SK'),'Content-Type: application/json'];
curl https://api.nexapay.net/v2/balance -H "Authorization: Bearer npay_live_sec_..."

Auth Error Codes

401
MISSING_TOKEN
No Authorization header provided.
401
INVALID_TOKEN
Malformed or unknown Bearer token.
403
IP_NOT_ALLOWED
IP whitelisting is enabled and this IP is not listed.
403
KEY_INACTIVE
API key exists but has been deactivated from the dashboard.

Test vs Live Keys

AttributeTest (npay_test_sec_)Live (npay_live_sec_)
Real moneyNoYes
Base URLsandbox.nexapay.net/v2api.nexapay.net/v2
Webhook eventsSimulatedReal
Rate limitsRelaxed (300 req/min)Standard (60 req/min)
DashboardTest sectionLive section

Rotating Keys Safely

Rotate your API keys without downtime using a two-step process: create a new key, deploy your updated environment variable, then deactivate the old key only after confirming the new key is in use.

1
Create a new key
In the dashboard, go to Settings → API Keys → New Key. Give it a label like npay_live_sec_v2. Copy it immediately — it's only shown once.
2
Update your environment
Set the new key in your server's environment variables (NEXAPAY_SK) and redeploy. Monitor logs to confirm requests are using the new key.
3
Deactivate the old key
Only after confirming the new key works in production, deactivate the old key in the dashboard. Any requests still using the old key will receive KEY_INACTIVE (403).

IP Whitelisting

Restrict your secret key to specific server IPs to prevent misuse if a key is leaked. Go to dashboard → Settings → IP Whitelist and add your server's egress IPs. Once a whitelist is set, requests from any other IP receive IP_NOT_ALLOWED (403). Leave the whitelist empty for no IP restriction.

Dynamic IPs and serverless functions
AWS Lambda, Google Cloud Run, Vercel functions, and similar platforms use dynamic egress IPs. Use IP ranges provided by your cloud vendor, or consider a NAT gateway with a static IP to make whitelisting viable.
Accept Payments

Charge mobile wallets — 4 countries

POST /v2/payments/charge — accepts ZMW, ZiG, USD, BWP, NAD.

ZM Zambia
ZW Zimbabwe
BW Botswana
NA Namibia
POST/v2/payments/charge
Initiates a mobile money collection from a customer's wallet. Customer receives a prompt on their handset. Requires secret key.

Request Parameters

ParameterTypeDescription
phone_numberrequiredstringE.164 without + — e.g. 260971234567, 263771234567, 26774123456, 264811234567
amountrequiredintegerSmallest unit: ngwee (ZMW), thebe (BWP), cents (ZiG/USD/NAD)
currencyrequiredstringZMW · ZiG · USD · BWP · NAD
networkrequiredstringSee networks table below per country
referencerequiredstringYour unique order ref. Max 64 chars. Must be globally unique.
descriptionoptionalstringShown to customer during payment prompt
callback_urloptionalstringPer-transaction webhook override (HTTPS)
metadataoptionalobjectAny key/value pairs stored on the transaction

Network Codes by Country

MTN Zambia
MTN
096x · 076x
Airtel Money
AIRTEL
097x · 077x
ZAMTEL
ZAMTEL
095x · 075x
EcoCash
ECOCASH
077x · 078x
OneMoney
ONEMONEY
071x
Orange Money
ORANGE
074x · 075x
Mascom MyZaka
MASCOM
071x · 072x
MTC Money
MTC
081x
FNB eWallet
FNB
060x
Use Webhooks — Not Polling
Status changes are async. Polling GET /v2/payments/{id} excessively returns HTTP 429. Subscribe to webhooks for real-time status updates instead.

Phone Prefix Lookup

Enter a phone number to identify its country and network

Live Request Builder

Build a charge request — generates real cURL & fetch code
Phone Number
Amount (smallest unit)
Currency
Network
Reference
Description (optional)
Generated cURL
Fill in the fields above to generate your request…
Checkout SDK

Drop-in payment UI in one line

Embed a fully styled, mobile-ready checkout modal with a single script tag. Supports all 4 countries automatically.

<script src="https://js.nexapay.net/v2/checkout.js"></script>
<button onclick="pay()">Pay Now</button>
<script>
function pay() {
  NexaPayCheckout.create({
    public_key: 'npay_live_pub_...',
    amount:     5000,    // in smallest unit
    currency:  'ZMW',   // ZMW | ZiG | USD | BWP | NAD
    reference: 'ORDER-001',
    customer: { name: 'Jane Banda', email: 'jane@example.com' },
    onSuccess: (d) => console.log(d.transaction_id),
    onClose:   () => console.log('closed')
  }).open();
}
</script>
import { useCallback } from 'react';
export function PayButton({ amount, currency, reference }) {
  const pay = useCallback(() => {
    window.NexaPayCheckout?.create({
      public_key: process.env.REACT_APP_NEXAPAY_PK,
      amount, currency, reference
    }).open();
  }, [amount, currency, reference]);
  return <button onClick={pay}>Pay with NexaPay</button>;
}
<template><button @click="pay">Pay</button></template>
<script setup>
const pay = () =>
  window.NexaPayCheckout?.create({
    public_key: import.meta.env.VITE_NEXAPAY_PK,
    amount: 5000, currency: 'BWP', reference: 'BW-VUE-001'
  }).open();
</script>
'use client';
import Script from 'next/script';
export function CheckoutButton() {
  return (
    <>
      <Script src="https://js.nexapay.net/v2/checkout.js" strategy="lazyOnload" />
      <button onClick={() =>
        window.NexaPayCheckout?.create({
          public_key: process.env.NEXT_PUBLIC_NEXAPAY_PK,
          amount: 35000, currency: 'NAD', reference: 'NA-NEXT-001'
        }).open()}>Pay</button>
    </>
  );
}
Never trust onSuccess alone
The onSuccess callback fires client-side and can be spoofed. Always confirm payment server-side by waiting for a signed webhook before fulfilling any order.

All Configuration Options

OptionTypeRequiredDescription
public_keystringrequiredYour npay_live_pub_… or npay_test_pub_… public key. Never use the secret key here.
amountintegerrequiredAmount in smallest currency unit (ngwee, thebe, cents).
currencystringrequiredZMW · ZiG · USD · BWP · NAD
referencestringrequiredYour unique order reference. Max 64 chars.
emailstringoptionalPre-fills the customer's email on the checkout form.
phonestringoptionalPre-fills the phone number field. E.164 without +.
namestringoptionalCustomer display name shown on checkout.
descriptionstringoptionalShort order description shown in the checkout modal. Max 120 chars.
logostring (URL)optionalYour logo URL shown at the top of the checkout modal. Must be HTTPS.
colorstring (hex)optionalBrand accent color for the checkout button and highlights. E.g. #3b5bdb
countriesstring[]optionalRestrict which country tabs are shown. E.g. ['ZM','ZW']. Default: all.
onSuccessfunctionoptionalClient-side callback with the payment object. Do not use to fulfill — use webhook instead.
onClosefunctionoptionalCalled when the user closes the modal without completing payment.
onErrorfunctionoptionalCalled if the SDK fails to load or an unexpected error occurs.

Themed Checkout Example

NexaPayCheckout.create({
  public_key:  'npay_live_pub_...',
  amount:      120000,
  currency:    'ZMW',
  reference:   'ORD-001',
  description: 'Annual Premium Plan',
  logo:        'https://myapp.com/logo.png',
  color:       '#1a6b3a',         // overrides the default brand blue
  countries: ['ZM'],              // only show Zambia tab
  phone:       '260971234567',  // pre-fill phone
  onSuccess:   txn => {
    // txn.transaction_id, txn.status — for UI only
    document.querySelector('#status').textContent = 'Payment received — confirming…';
  },
  onClose:     () => console.log('User closed checkout'),
  onError:     err => console.error('SDK error', err)
}).open();
Payment Links

Share a link, get paid instantly

Create fixed or flexible payment links — no coding needed on the customer's side.

POST/v2/payment-links
Create a new shareable payment link.
GET/v2/payment-links
List all payment links with pagination.
PATCH/v2/payment-links/{id}
Update title, amount, or status.
DELETE/v2/payment-links/{id}
Deactivate a payment link permanently.

Create a Link — All Countries

const { data } = await (await fetch('https://api.nexapay.net/v2/payment-links', {
  method: 'POST', headers: { 'Authorization': `Bearer ${SK}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ title: 'School Fees Term 3', amount: 120000, currency: 'ZMW', amount_type: 'fixed' })
})).json();
console.log(data.url); // → https://pay.nexapay.net/l/abc123
curl -X POST https://api.nexapay.net/v2/payment-links \
  -H "Authorization: Bearer npay_live_sec_..." \
  -d '{"title":"School Fees","amount":120000,"currency":"ZMW","amount_type":"fixed"}'
// Zimbabwe — flexible USD link (customer sets amount)
body: JSON.stringify({ title: 'Donate', currency: 'USD', type: 'flexible' })
curl -X POST https://api.nexapay.net/v2/payment-links \
  -H "Authorization: Bearer npay_live_sec_..." \
  -d '{"title":"Donate","currency":"USD","type":"flexible"}'
// Botswana — fixed BWP subscription link
body: JSON.stringify({ title: 'Monthly Sub', amount: 20000, currency: 'BWP', amount_type: 'fixed' })
curl -X POST https://api.nexapay.net/v2/payment-links \
  -d '{"title":"Monthly Sub","amount":20000,"currency":"BWP","amount_type":"fixed"}'
// Namibia — fixed NAD invoice link
body: JSON.stringify({ title: 'Invoice #882', amount: 45000, currency: 'NAD', amount_type: 'fixed' })
curl -X POST https://api.nexapay.net/v2/payment-links \
  -d '{"title":"Invoice #882","amount":45000,"currency":"NAD","amount_type":"fixed"}'

Request Parameters — POST /v2/payment-links

ParameterTypeDescription
titlerequiredstringDisplayed on the payment page. Max 120 chars. E.g. School Fees Term 3
currencyrequiredstringZMW · ZiG · USD · BWP · NAD
amount_typerequiredstringfixed — set amount; flexible — customer sets amount; pwyw — pay-what-you-want with a minimum
amountoptionalintegerRequired when amount_type=fixed. Smallest currency unit (ngwee/thebe/cents). Omit for flexible/pwyw links.
min_amountoptionalintegerMinimum accepted amount for type=pwyw or flexible links.
descriptionoptionalstringLonger description shown below the title on the payment page. Max 500 chars.
redirect_urloptionalstringURL to redirect the customer to after payment. Must be HTTPS.
expires_atoptionalISO 8601Deactivates the link at this UTC timestamp. E.g. 2025-12-31T23:59:00Z
max_usesoptionalintegerAuto-deactivate after N successful payments. Default: unlimited.
collect_emailoptionalbooleanIf true, the payment page prompts for the customer's email address. Default: false.
collect_nameoptionalbooleanIf true, prompts for full name. Default: false.
metadataoptionalobjectUp to 10 key-value pairs. Echoed back in webhook payloads for your reference.

Response Body

{
  "status": "success",
  "data": {
    "id":          "pl_01J9XKABCDEF",
    "url":         "https://pay.nexapay.net/l/abc123",
    "qr_url":      "https://api.nexapay.net/v2/payment-links/pl_01J9XK.../qr",
    "title":       "School Fees Term 3",
    "currency":    "ZMW",
    "type":        "fixed",
    "amount":      120000,
    "status":      "active",
    "usage_count": 0,
    "max_uses": null,
    "expires_at":  null,
    "created_at":  "2025-09-01T10:22:00Z"
  }
}

Link Types

fixed
Set amount
You define the amount at creation. Customer cannot change it. Best for invoices and school fees.
flexible
Customer sets it
Customer enters any amount ≥ min_amount. Ideal for donations and fundraisers.
pwyw
Pay what you want
Customer pays any amount with an optional suggested price. Great for tip jars and community contributions.

List & Manage Links

// List all payment links (paginated)
const list = await (await fetch(
  'https://api.nexapay.net/v2/payment-links?status=active&page=1&limit=20',
  { headers: { Authorization: `Bearer ${SK}` } }
)).json();

// Update a link (change title or deactivate)
await fetch('https://api.nexapay.net/v2/payment-links/pl_01J9XK...', {
  method: 'PATCH',
  headers: { 'Authorization': `Bearer ${SK}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ status: 'inactive' })  // deactivate
});

// Get QR code PNG (returns binary, save as .png)
const qr = await fetch(
  'https://api.nexapay.net/v2/payment-links/pl_01J9XK.../qr',
  { headers: { Authorization: `Bearer ${SK}` } }
);
# List active links
curl 'https://api.nexapay.net/v2/payment-links?status=active&limit=20' \
  -H "Authorization: Bearer npay_live_sec_..."

# Deactivate a link
curl -X PATCH https://api.nexapay.net/v2/payment-links/pl_01J9XK... \
  -H "Authorization: Bearer npay_live_sec_..." \
  -d '{"status":"inactive"}'

# Download QR code PNG
curl https://api.nexapay.net/v2/payment-links/pl_01J9XK.../qr \
  -H "Authorization: Bearer npay_live_sec_..." \
  --output link-qr.png
Webhooks for Payment Links
Payments made through a link still fire payin.success and payin.failed webhooks. The webhook payload includes a payment_link_id field so you can attribute which link triggered the payment.

Link Status Lifecycle

A payment link moves through these statuses: activeinactive (manually deactivated or expired) or completed (max_uses reached). Inactive and completed links return a friendly expiry page to the customer. You can reactivate an inactive link via PATCH — but not a completed one.

active
Accepting
Link is live and accepting payments.
inactive
Paused
Manually paused or expires_at passed. Reactivatable.
completed
Closed
max_uses reached. Cannot be re-opened.
Payouts

Send money to any mobile wallet

Disburse funds to customers, suppliers, or agents across all 4 countries from your NexaPay wallet balance.

Balance Required
Payouts debit your NexaPay wallet. Check available balance first with GET /v2/balance before initiating large disbursements.

Send Payout — All Countries

// Zambia payout — ZMW · MTN | AIRTEL | ZAMTEL
await fetch('https://api.nexapay.net/v2/payouts', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${SK}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    phone_number:   '260971234567',
    amount:         100000,  // 1,000 ZMW
    currency:       'ZMW',
    network:        'MTN',
    reference:      'POUT-ZM-001',
    recipient_name: 'John Phiri',
    narration:      'Commission'
  })
});
# Zambia payout — ZMW · AIRTEL
requests.post('https://api.nexapay.net/v2/payouts',
  headers={'Authorization':f"Bearer {sk}"},
  json={'phone_number':'260977654321','amount':50000,
       'currency':'ZMW','network':'AIRTEL','reference':'POUT-ZM-002'})
curl -X POST https://api.nexapay.net/v2/payouts \
  -H "Authorization: Bearer npay_live_sec_..." \
  -d '{"phone_number":"260971234567","amount":100000,"currency":"ZMW","network":"MTN","reference":"POUT-ZM-001","recipient_name":"John Phiri"}'
// Zimbabwe payout — USD · ECOCASH
body: JSON.stringify({
  phone_number: '263771234567', amount: 1000,
  currency: 'USD', network: 'ECOCASH',
  reference: 'POUT-ZW-001', recipient_name: 'Mary Dube'
})
curl -X POST https://api.nexapay.net/v2/payouts \
  -H "Authorization: Bearer npay_live_sec_..." \
  -d '{"phone_number":"263771234567","amount":1000,"currency":"USD","network":"ECOCASH","reference":"POUT-ZW-001"}'
// Botswana payout — BWP · ORANGE
body: JSON.stringify({
  phone_number: '26774123456', amount: 15000,
  currency: 'BWP', network: 'ORANGE',
  reference: 'POUT-BW-001', recipient_name: 'Thabo Mokoena'
})
curl -X POST https://api.nexapay.net/v2/payouts \
  -d '{"phone_number":"26774123456","amount":15000,"currency":"BWP","network":"ORANGE","reference":"POUT-BW-001"}'
// Namibia payout — NAD · MTC | FNB
body: JSON.stringify({
  phone_number: '264811234567', amount: 50000,
  currency: 'NAD', network: 'MTC',
  reference: 'POUT-NA-001', recipient_name: 'Ndapewa Simon'
})
curl -X POST https://api.nexapay.net/v2/payouts \
  -d '{"phone_number":"264811234567","amount":50000,"currency":"NAD","network":"MTC","reference":"POUT-NA-001"}'

Request Parameters — POST /v2/payouts

ParameterTypeDescription
phone_numberrequiredstringRecipient's number in E.164 format without +. E.g. 260971234567, 263771234567
amountrequiredintegerSmallest currency unit. 100000 = 1,000 ZMW. Must be ≥ the per-network minimum.
currencyrequiredstringZMW · ZiG · USD · BWP · NAD
networkrequiredstringMust match the recipient's network. ZMW: MTN·AIRTEL·ZAMTEL; ZiG/USD: ECOCASH·ONEMONEY; BWP: ORANGE·MASCOM; NAD: MTC·FNB
referencerequiredstringYour unique payout reference. Max 64 chars. Used for idempotency — duplicate references return the original payout.
recipient_nameoptionalstringShown in your dashboard and payout receipts. Recommended for audit purposes.
narrationoptionalstringNarrative sent with the payout (visible to recipient on some networks). Max 80 chars.
callback_urloptionalstringOverride the default webhook URL for this payout only. Must be HTTPS.
metadataoptionalobjectUp to 10 key-value pairs echoed back in the payout.success / payout.failed webhook.

Check Balance Before Disbursing

const bal = await (await fetch('https://api.nexapay.net/v2/balance', {
  headers: { 'Authorization': `Bearer ${SK}` }
})).json();
// Response: { data: { ZMW: 5000000, ZiG: 0, USD: 25000, BWP: 120000, NAD: 300000 } }

const payoutAmount = 100000;  // 1,000 ZMW
if (bal.data.ZMW < payoutAmount) {
  throw new Error('Insufficient ZMW balance for payout');
}
curl https://api.nexapay.net/v2/balance \
  -H "Authorization: Bearer npay_live_sec_..."

Payout Response & Lifecycle

{
  "status": "success",
  "data": {
    "payout_id":      "pout_01J9XKABCDEF",
    "status":         "pending",   // pending → success | failed
    "phone_number":   "260971234567",
    "amount":         100000,
    "currency":       "ZMW",
    "network":        "MTN",
    "reference":      "POUT-ZM-001",
    "recipient_name": "John Phiri",
    "fee":            0,            // no payout fee on NexaPay
    "created_at":     "2025-09-01T11:00:00Z",
    "completed_at":   null
  }
}
pending
Processing
Network processing. Usually completes in <60 seconds.
success
Delivered
Funds credited to recipient's wallet. payout.success webhook fires.
failed
Rejected
Network rejected (wrong number, closed wallet). Balance refunded. payout.failed fires.

Bulk Payouts

Send up to 100 payouts in a single request using the batch endpoint. Each item in the array follows the same schema as a single payout. The batch is processed asynchronously — you receive individual payout.success / payout.failed webhooks per item.

const res = await fetch('https://api.nexapay.net/v2/payouts/batch', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${SK}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    payouts: [
      { phone_number: '260971234567', amount: 50000, currency: 'ZMW', network: 'MTN',    reference: 'BULK-001' },
      { phone_number: '260977654321', amount: 75000, currency: 'ZMW', network: 'AIRTEL', reference: 'BULK-002' },
      { phone_number: '263771234567', amount: 1000,  currency: 'USD', network: 'ECOCASH', reference: 'BULK-003' }
    ]
  })
});
// Returns: { batch_id: "btch_...", total: 3, queued: 3, rejected: 0 }
curl -X POST https://api.nexapay.net/v2/payouts/batch \
  -H "Authorization: Bearer npay_live_sec_..." \
  -H "Content-Type: application/json" \
  -d '{"payouts":[{"phone_number":"260971234567","amount":50000,"currency":"ZMW","network":"MTN","reference":"BULK-001"},{"phone_number":"260977654321","amount":75000,"currency":"ZMW","network":"AIRTEL","reference":"BULK-002"}]}'
Idempotency on Payouts
The reference field acts as an idempotency key. Submitting the same reference twice returns the original payout object instead of creating a duplicate — safe to retry on network errors.
Webhooks

Real-time payment notifications

NexaPay POSTs HMAC-SHA256 signed events to your server the moment status changes. Always verify the signature.

Events

payin.success
Customer approved & funds received
payin.failed
Customer declined or timed out
payment.pending
Awaiting customer action
payment.cancelled
Cancelled before processing
payout.success
Disbursement delivered
payout.failed
Disbursement rejected by network
Always verify X-NexaPay-Signature
Any server can POST to your endpoint. Only process requests whose HMAC-SHA256 signature — computed over the raw body with your webhook secret — matches the header value. Use timing-safe comparison.

Signature Verification

import crypto from 'node:crypto';
function verify(rawBody, sig, secret) {
  const expected = crypto.createHmac('sha256',secret).update(rawBody).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected),Buffer.from(sig));
}
// Express — use express.raw() to get the raw body buffer
app.post('/webhooks/nexapay', express.raw({type:'application/json'}), (req,res) => {
  if (!verify(req.body,req.headers['x-nexapay-signature'],process.env.WH_SECRET))
    return res.sendStatus(401);
  const event = JSON.parse(req.body);
  if (event.type === 'payin.success') fulfillOrder(event.data);
  res.sendStatus(200); // Respond within 5 seconds
});
import hmac, hashlib, os
def verify(body,sig):
  s = os.environ['WH_SECRET'].encode()
  return hmac.compare_digest(hmac.new(s,body,hashlib.sha256).hexdigest(),sig)
@app.route('/webhooks/nexapay',methods=['POST'])
def webhook():
  if not verify(request.data,request.headers.get('X-NexaPay-Signature','')): abort(401)
  e = request.get_json()
  if e['type'] == 'payin.success': fulfill(e['data'])
  return '',200
$body=file_get_contents('php://input');
$sig=$_SERVER['HTTP_X_NEXAPAY_SIGNATURE']??'';
if(!hash_equals(hash_hmac('sha256',$body,getenv('WH_SECRET')),$sig)){http_response_code(401);exit();}
$e=json_decode($body,true);
if($e['type']==='payin.success')fulfillOrder($e['data']);
http_response_code(200);

Retry Schedule

Non-2xx responses trigger retries: immediate → 1 min → 5 min → 30 min → 2 hr → 6 hr. After 6 failures the event is marked dead and visible in your dashboard. Respond within 5 seconds — offload processing to a background queue.

AttemptDelay after previousCumulative timeAction if failure
1 — Immediate0 s0 sProceed to attempt 2
260 s1 minProceed to attempt 3
34 min5 minProceed to attempt 4
425 min30 minProceed to attempt 5
590 min2 hrProceed to attempt 6
6 — Final4 hr6 hrMark event dead. Visible in dashboard for manual replay.

Full Event Payload Examples

{
  "id":        "evt_01J9XKABCDEF",          // unique per delivery — use for dedup
  "type":      "payin.success",
  "created_at": "2025-09-01T11:05:32Z",
  "data": {
    "transaction_id": "txn_01J9XKABCDEF",
    "reference":      "ORD-001",
    "phone_number":   "260971234567",
    "amount":         50000,
    "currency":       "ZMW",
    "network":        "MTN",
    "fee":            1300,           // 2.6% of 50000
    "net":            48700,           // credited to your wallet
    "status":         "success",
    "payment_link_id": null,            // set if via a payment link
    "metadata": { "order_id": "ORD-001" },
    "completed_at":   "2025-09-01T11:05:30Z"
  }
}
{
  "id":        "evt_01J9XKFAILED",
  "type":      "payin.failed",
  "created_at": "2025-09-01T11:06:10Z",
  "data": {
    "transaction_id": "txn_01J9XKFAILED",
    "reference":      "ORD-002",
    "phone_number":   "260971234567",
    "amount":         50000,
    "currency":       "ZMW",
    "network":        "MTN",
    "status":         "failed",
    "failure_reason": "CUSTOMER_DECLINED",
    "fee":            0,             // no fee on failures
    "metadata": {},
    "failed_at":      "2025-09-01T11:06:08Z"
  }
}
/* failure_reason values: CUSTOMER_DECLINED · TIMEOUT · INSUFFICIENT_FUNDS
   INVALID_PIN · NETWORK_ERROR · ACCOUNT_BLOCKED */
{
  "id":        "evt_01J9XKPAYOUT",
  "type":      "payout.success",
  "created_at": "2025-09-01T11:01:45Z",
  "data": {
    "payout_id":      "pout_01J9XKABCDEF",
    "reference":      "POUT-ZM-001",
    "phone_number":   "260971234567",
    "recipient_name": "John Phiri",
    "amount":         100000,
    "currency":       "ZMW",
    "network":        "MTN",
    "status":         "success",
    "metadata": { "agent_id": "AGT-045" },
    "completed_at":   "2025-09-01T11:01:43Z"
  }
}

Register & Manage Webhooks

POST/v2/webhooks
Register an endpoint to receive events. You can register multiple URLs and subscribe each to different event types.
// Register a new webhook
await fetch('https://api.nexapay.net/v2/webhooks', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${SK}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    url:    'https://myapp.com/webhooks/nexapay',
    events: ['payin.success', 'payin.failed', 'payout.success', 'payout.failed']
    // omit events to subscribe to all event types
  })
});
// Returns: { id: "wh_01J9...", secret: "whsec_...", url: "...", events: [...] }
// Store the returned secret — it is shown only once.
curl -X POST https://api.nexapay.net/v2/webhooks \
  -H "Authorization: Bearer npay_live_sec_..." \
  -d '{"url":"https://myapp.com/webhooks/nexapay","events":["payin.success","payin.failed"]}'

Testing Webhooks in Sandbox

The sandbox environment lets you simulate any event type without making real network calls. POST to POST /v2/webhooks/test with event_type and optional overrides to fire a test delivery to your registered URL immediately.

# Fire a synthetic payin.success to your endpoint
curl -X POST https://sandbox.nexapay.net/v2/webhooks/test \
  -H "Authorization: Bearer npay_test_sec_..." \
  -d '{"event_type":"payin.success","webhook_id":"wh_01J9...","overrides":{"amount":10000,"currency":"ZMW"}}'
await fetch('https://sandbox.nexapay.net/v2/webhooks/test', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${SK_TEST}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    event_type:  'payin.failed',
    webhook_id:  'wh_01J9...',
    overrides: { failure_reason: 'INSUFFICIENT_FUNDS' }
  })
});
Dedup with event.id
Store processed event.id values in a set (e.g. Redis or a DB column). Before acting on an event, check if you've already handled it — this makes your handler safe against duplicate deliveries during retries.
Error Handling

Understand and handle errors

Standard HTTP codes + a machine-readable error_code field so your code can branch precisely.

200
OK
Check status: "success" in the body.
400
Bad Request
Missing or invalid field. Check the errors object for field-level detail.
401
Unauthorized
Missing or invalid Bearer token.
403
Forbidden
Valid token but insufficient permissions or IP not whitelisted.
404
Not Found
Resource does not exist or belongs to a different account.
409
Conflict — DUPLICATE_REFERENCE
A transaction with this reference already exists. Use a unique reference per payment.
422
Unprocessable
Valid JSON but semantically wrong — amount exceeds limit, network mismatch, currency not supported in country.
429
Too Many Requests
Rate limit exceeded. Check Retry-After header and back off exponentially.
500
Server Error
Internal NexaPay error. Safe to retry with exponential back-off. Contact support@nexapay.net if persistent.
const body = await res.json();
if (!res.ok) {
  switch (body.error_code) {
    case 'DUPLICATE_REFERENCE': throw new Error('Order already paid');
    case 'INVALID_PHONE':       throw new Error('Phone number format invalid');
    case 'INSUFFICIENT_BALANCE': throw new Error('Customer wallet low funds');
    case 'NETWORK_MISMATCH':    throw new Error('Network code wrong for this phone');
    default:                    throw new Error(body.message ?? 'Payment failed');
  }
}
body = res.json()
if not res.ok:
  code = body.get('error_code')
  MAP = {'DUPLICATE_REFERENCE':'Already paid','INVALID_PHONE':'Bad phone'}
  raise ValueError(MAP.get(code, body.get('message','Failed')))
$b=json_decode($body,true);
switch($b['error_code']??''){
  case 'DUPLICATE_REFERENCE': throw new Exception('Already paid');
  case 'NETWORK_MISMATCH':    throw new Exception('Wrong network code');
  default:                    throw new Exception($b['message']??'Failed');
}

Full Error Code Reference

HTTPerror_codeWhen it occursFix
400MISSING_FIELDA required field is absent.Check the errors object for the specific field name.
400INVALID_PHONEPhone number is not valid E.164 for the given country.Use the phone validator on the Accept Payments page to diagnose.
400INVALID_AMOUNTAmount is 0, negative, or below the network minimum.Ensure amount ≥ 1 and above the per-network floor.
400CURRENCY_NOT_SUPPORTEDCurrency is not accepted in the detected country.Match currency to country — e.g. ZMW only for +260 numbers.
400NETWORK_MISMATCHNetwork code does not match the phone prefix.Check the Countries & Networks page for correct prefix/network pairs.
401MISSING_TOKENNo Authorization header provided.Add Authorization: Bearer npay_live_sec_… to every request.
401INVALID_TOKENToken is malformed or revoked.Regenerate the key from the dashboard.
403IP_NOT_ALLOWEDIP whitelist is on and caller IP is not listed.Add the caller IP in dashboard → Settings → IP Whitelist.
403KEY_INACTIVEKey was manually deactivated.Create a new key in the dashboard.
403PUBLIC_KEY_NOT_ALLOWEDAttempt to use pk_ key on a server-only endpoint.Use the secret key (sk_) for charges and payouts.
404NOT_FOUNDResource does not exist or belongs to another account.Verify the ID is correct and belongs to your account.
409DUPLICATE_REFERENCEA transaction with this reference already exists.Use a unique reference per payment. Fetch the existing transaction to check its status.
422LIMIT_EXCEEDEDAmount exceeds single-transaction or daily limit.Split into smaller transactions or contact support to raise limits.
422INSUFFICIENT_WALLET_BALANCEYour NexaPay wallet has insufficient funds for a payout.Top up your wallet before initiating payouts.
429RATE_LIMIT_EXCEEDEDYou exceeded the per-minute request quota.Read Retry-After header and implement exponential backoff.
500INTERNAL_ERRORUnexpected NexaPay server error.Safe to retry. Contact support@nexapay.net if the error persists.
503SERVICE_UNAVAILABLENexaPay or an upstream network is temporarily down.Back off and retry. Contact support@nexapay.net if the outage persists.

Exponential Backoff Strategy

async function fetchWithRetry(url, opts, maxAttempts = 5) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    const res = await fetch(url, opts);
    if (res.status === 429 || res.status >= 500) {
      const retryAfter = res.headers.get('Retry-After');
      const wait = retryAfter
        ? parseInt(retryAfter) * 1000
        : (2 ** attempt) * 1000 + Math.random() * 200;  // jitter
      await new Promise(r => setTimeout(r, wait));
      continue;
    }
    return res;
  }
  throw new Error(`Failed after ${maxAttempts} attempts`);
}
import time, random, requests

def fetch_with_retry(url, **kwargs):
    for attempt in range(5):
        res = requests.request(url=url, **kwargs)
        if res.status_code in (429,) or res.status_code >= 500:
            retry_after = int(res.headers.get('Retry-After', 2 ** attempt))
            jitter      = random.uniform(0, 0.3)
            time.sleep(retry_after + jitter)
            continue
        return res
    raise RuntimeError('Failed after 5 attempts')
Never retry DUPLICATE_REFERENCE (409)
A 409 means the reference already exists. Retrying will not change the outcome — instead, fetch GET /v2/payments?reference=YOUR_REF to retrieve the existing transaction and check its status.
Countries & Networks

4 countries, 9+ networks

Complete reference of supported countries, currencies, network codes, phone prefixes, and transaction limits.

Zambia — ZMW

Dial prefix +260. Amounts in ngwee (1 ZMW = 100 ngwee). Phone numbers passed without + in API.

MTN Zambia
MTN Zambia
MTN
096x · 076x
Airtel Zambia
Airtel Money
AIRTEL
097x · 077x
ZAMTEL Zambia
ZAMTEL
ZAMTEL
095x · 075x
Currency
ZMW
Min Charge
100 ngwee
= 1.00 ZMW
Max Single Tx
150,000 ZMW
Fee
2.6% success
0% on failures

Zimbabwe — ZiG / USD

Dial prefix +263. Accepts both ZiG (Zimbabwe Gold) and USD. Amounts in cents.

EcoCash Zimbabwe
EcoCash
ECOCASH
077x · 078x
OneMoney Zimbabwe
OneMoney
ONEMONEY
071x
Currencies
ZiG · USD
Min Charge (ZiG)
100 cents
Min Charge (USD)
$0.01
Fee
2.6% success

Botswana — BWP

Dial prefix +267. Amounts in thebe (1 BWP = 100 thebe). Botswana numbers are 8 digits.

Orange Money Botswana
Orange Money
ORANGE
074x · 075x
Mascom MyZaka Botswana
Mascom MyZaka
MASCOM
071x · 072x
Currency
BWP
Min Charge
100 thebe
Max Single Tx
50,000 BWP
Fee
2.6% success

Namibia — NAD

Dial prefix +264. Amounts in cents (1 NAD = 100 cents).

MTC Namibia
MTC Money
MTC
081x
FNB eWallet
FNB
060x
Currency
NAD
Min Charge
100 cents
Max Single Tx
100,000 NAD
Fee
2.6% success

Settlement Times & Daily Limits

NetworkCurrencyTypical settlementMax single txDaily limit
MTN ZambiaZMW< 30 seconds150,000 ZMW500,000 ZMW
Airtel MoneyZMW< 30 seconds150,000 ZMW500,000 ZMW
ZAMTELZMW< 60 seconds50,000 ZMW200,000 ZMW
EcoCashZiG / USD< 45 secondsUSD 500USD 2,000
OneMoneyZiG / USD< 60 secondsUSD 300USD 1,000
Orange MoneyBWP< 30 seconds50,000 BWP100,000 BWP
Mascom MyZakaBWP< 30 seconds30,000 BWP80,000 BWP
MTC MoneyNAD< 45 seconds100,000 NAD300,000 NAD
FNB eWalletNAD1–3 minutes50,000 NAD150,000 NAD
Settlement times are indicative
Settlement depends on network load and telco availability. During peak hours (6–9 PM local) expect occasional delays of up to 3 minutes. Always wait for the webhook rather than polling for status.

Phone Number Format Guide

All phone numbers must be passed in E.164 format without the leading +. Do not include spaces, dashes, or parentheses. If a customer provides a local number with a leading 0, strip the 0 and prepend the country code.

CountryCountry codeLocal formatAPI formatExample
ZM Zambia26009X XXX XXXX260 9X XXX XXXX260971234567
ZW Zimbabwe26307X XXX XXXX263 7X XXX XXXX263771234567
BW Botswana2677X XXX XXX (8 digits)267 7X XXX XXX26774123456
NA Namibia26408X XXX XXXX264 8X XXX XXXX264811234567
/** Convert local or E.164 phone to NexaPay API format */
function toApiPhone(raw, countryCode) {
  const digits = raw.replace(/\D/g, '');  // strip non-digits
  if (digits.startsWith(countryCode)) return digits;
  if (digits.startsWith('0')) return countryCode + digits.slice(1);
  return countryCode + digits;
}
toApiPhone('0971 234 567', '260');  // → '260971234567'
toApiPhone('+263771234567', '263');  // → '263771234567'
import re
def to_api_phone(raw: str, country_code: str) -> str:
    digits = re.sub(r'\D', '', raw)
    if digits.startswith(country_code): return digits
    if digits.startswith('0'):             return country_code + digits[1:]
    return country_code + digits
API Reference

Full endpoint reference — v2

Base URL:

Payments

POST/v2/payments/charge
Initiate mobile money collection. Requires secret key.
GET/v2/payments/{id}
Retrieve single payment by transaction_id.
GET/v2/payments?status=&page=&limit=
List payments. Filter by pending · success · failed.

Payouts

POST/v2/payouts
Send money to a recipient's mobile wallet.
GET/v2/payouts/{id}
Check payout status.

Payment Links

POST/v2/payment-links
Create a shareable payment link.
GET/v2/payment-links
List all payment links.
PATCH/v2/payment-links/{id}
Update link title, amount, or status.
DELETE/v2/payment-links/{id}
Deactivate a payment link permanently.

Webhooks & Account

POST/v2/webhooks
Register a webhook URL and event subscriptions.
GET/v2/webhooks
List registered webhooks.
DELETE/v2/webhooks/{id}
Remove a webhook endpoint.
GET/v2/balance
Check wallet balance across all currencies.
GET/v2/account
Retrieve account profile and settings.

Rate Limits

Charge
60 / min
Payout
30 / min
GET endpoints
300 / min
Burst
10 / sec
429 Too Many Requests
Implement exponential backoff: wait 2n seconds where n is consecutive failures. The response includes Retry-After in seconds.

Common Request Headers

HeaderRequiredValue
AuthorizationrequiredBearer npay_live_sec_… — your secret key. Use npay_test_sec_… for sandbox.
Content-Typerequired (POST/PATCH)application/json
Idempotency-KeyoptionalAny UUID or unique string. Makes the request safe to retry — same key returns the cached response for 24 hours.
X-NexaPay-VersionoptionalPin to a specific API version, e.g. 2025-01-01. Omit to use your account's default version.
Accept-LanguageoptionalISO 639-1 code. Used to localise error messages. Supported: en · sn · ny

Full Charge Endpoint — Request & Response

POST/v2/payments/charge
Initiate a mobile money collection from a customer's wallet. Customer receives a USSD push or prompt. Responds within 200 ms — actual settlement is async and delivered via webhook.
POST /v2/payments/charge HTTP/1.1
Host: api.nexapay.net
Authorization: Bearer npay_live_sec_...
Content-Type: application/json
Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890

{
  "phone_number":  "260971234567",
  "amount":       50000,
  "currency":     "ZMW",
  "network":      "MTN",
  "reference":    "ORD-001",
  "description":  "Electricity top-up",
  "callback_url": "https://myapp.com/webhooks/nexapay",
  "metadata": { "user_id": "usr_888", "plan": "premium" }
}
HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "success",
  "data": {
    "transaction_id":   "txn_01J9XKABCDEF",
    "status":           "pending",
    "phone_number":     "260971234567",
    "amount":           50000,
    "currency":         "ZMW",
    "network":          "MTN",
    "reference":        "ORD-001",
    "ussd_code":        null,           // set on networks that use USSD
    "expires_at":       "2025-09-01T11:05:00Z",  // customer has 3 min
    "created_at":       "2025-09-01T11:02:00Z"
  }
}
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "status":     "error",
  "message":    "Validation failed",
  "error_code": "MISSING_FIELD",
  "errors": {
    "network": "network is required",
    "amount":  "amount must be a positive integer"
  }
}

Pagination

All list endpoints return paginated results. Use page (1-based) and limit (max 100, default 20) query parameters. The response includes a meta object with total count and page info.

GET /v2/payments?status=success&page=2&limit=50&from=2025-01-01&to=2025-09-30 HTTP/1.1
Authorization: Bearer npay_live_sec_...
{
  "status": "success",
  "data": [ { ... }, { ... } ],
  "meta": {
    "total":       1843,
    "page":        2,
    "limit":       50,
    "total_pages": 37,
    "has_next":    true,
    "has_prev":    true
  }
}

Idempotency

Pass a unique Idempotency-Key header on all POST requests to safely retry on network failures. NexaPay caches the response for that key for 24 hours. The same key from the same account always returns the same response — no duplicate charge or payout is created.

import { randomUUID } from 'node:crypto';
// Derive a stable key from your order ID so retries reuse the same key
const idempotencyKey = `charge-${orderId}`;

await fetch('https://api.nexapay.net/v2/payments/charge', {
  method: 'POST',
  headers: {
    'Authorization':   `Bearer ${SK}`,
    'Content-Type':    'application/json',
    'Idempotency-Key': idempotencyKey       // safe to retry
  },
  body: JSON.stringify({ ... })
});
curl -X POST https://api.nexapay.net/v2/payments/charge \
  -H "Authorization: Bearer npay_live_sec_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: charge-ORD-001" \
  -d '{"phone_number":"260971234567","amount":50000,"currency":"ZMW","network":"MTN","reference":"ORD-001"}'

Versioning & Deprecation Policy

NexaPay follows calendar-based API versioning. The current stable version is v2 (released January 2025). Breaking changes are never introduced within a major version. When a new major version is released, the previous one is supported for at least 12 months before sunset. You will receive email notice at 90 days, 30 days, and 7 days before deprecation.

Current
v2
Released 2025-01-15. Stable.
Previous
v1
Sunset 2026-01-15. Migrate now.
Sandbox
sandbox.nexapay.net/v2
Always mirrors production.
Status page
support@nexapay.net
Report incidents & get support.
Changelog

What's new in NexaPay API

Release history, fixes, and breaking changes — ordered newest first.

2025 Releases

12Jun
New
Live Request Builder & Phone Prefix Validator
Interactive tools on the Accept Payments page — build a real cURL command from a form, and identify any phone number's network and country by prefix in real-time.
01Jun
New
Namibia (NAD) — MTC Money & FNB eWallet
Full support for Namibia added: NAD currency, MTC Money (081x) and FNB eWallet (060x). Charge and payout endpoints fully supported.
15May
Improved
Webhook retry schedule extended to 6 attempts
The webhook delivery schedule now retries at 1 min, 5 min, 30 min, 2 hr, 6 hr, and 24 hr. Failed deliveries beyond 24 hr are moved to the dead-letter queue in the dashboard.
02May
New
API v2 — General Availability
API v2 is now generally available. New features include: unified charge endpoint for all 4 countries, idempotency keys, metadata object on all transactions, per-transaction callback_url, and improved error codes.
18Apr
Fixed
ZAMTEL 075x prefix recognition
Numbers starting 2600750–2600759 were incorrectly rejected at validation. Fixed — ZAMTEL 075x prefixes are now correctly identified and routed.
03Apr
New
Payment Links — flexible amount mode
Payment links now support "type":"flexible" — customers can enter their own amount at checkout. Useful for donations and top-ups. Minimum and maximum amounts can be enforced via min_amount / max_amount.
10Mar
Breaking
v1 deprecated — migrating to v2
API v1 (api.nexapay.net/v1) will stop accepting new traffic on 31 December 2025. Migrate all calls to /v2 and replace network_code (v1) with network (v2) in your request bodies.
20Feb
Improved
Rate limit headers on all responses
All API responses now include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers. Use these to implement adaptive throttling in your integration.
05Jan
New
Botswana (BWP) — Orange & Mascom MyZaka
Botswana support launched with Orange Money (074x, 075x) and Mascom MyZaka (071x, 072x). BWP is denominated in thebe (100 thebe = 1 BWP).

2024 Releases

15Nov
New
Zimbabwe (ZiG & USD) — EcoCash & OneMoney
Zimbabwe launched supporting EcoCash (077x, 078x) and OneMoney (071x). Both ZiG and USD are accepted. USD amounts use cents as the smallest unit.
01Aug
New
NexaPay API launches — Zambia (ZMW)
Initial launch with Zambia support: MTN Money, Airtel Money, and ZAMTEL Kwacha. Sandbox environment available at sandbox.nexapay.net/v2.

Overview

KYC verification is enforced at the merchant account level via the verification_status field on the profiles table. Until a merchant's status reaches verified, all payment endpoints return HTTP 403 with error code MERCHANT_NOT_VERIFIED.

Storage
Documents are uploaded to the Supabase Storage bucket kyc-documents. Each file is stored under the path {user_id}/{document_type}/{filename} and is only accessible to NexaPay staff reviewers.

Verification statuses

StatusDescriptionPayment access
pendingAccount created; no documents submitted yet.Sandbox only
submittedDocuments uploaded; under manual review (1–3 business days).Sandbox only
verifiedReview passed. Account fully active.Full live access
rejectedReview failed. Merchant must re-submit corrected documents.Sandbox only

Accepted Documents by Country

The document_type parameter in the upload API indicates the document category. A selfie (liveness photo) is always required alongside the primary document.

ZM Zambia (ZMW)

Documentdocument_type valueNotes
National Registration Card (NRC)zm_nrcFront and back scan required. Must be valid (not expired).
Passport (SADC/COMESA)zm_passportBio-data page + photo page. Valid for at least 6 months.
Selfie / LivenessselfieClear face photo, no sunglasses. JPEG or PNG, max 5 MB.

ZW Zimbabwe (ZiG / USD)

Documentdocument_type valueNotes
National ID (Proof of Citizenship)zw_national_idBoth sides. Must be a Zimbabwean National ID Card.
Passportzw_passportBio-data page. Valid passport; not expired.
Selfie / LivenessselfieClear face photo, no sunglasses. JPEG or PNG, max 5 MB.

BW Botswana (BWP)

Documentdocument_type valueNotes
Omang (National ID)bw_omangFront and back. The Omang is Botswana's primary citizen ID.
Passportbw_passportBio-data page. Must be a Botswana passport, not expired.
Selfie / LivenessselfieClear face photo, no sunglasses. JPEG or PNG, max 5 MB.

NA Namibia (NAD)

Documentdocument_type valueNotes
Namibian National Identity Documentna_national_idGreen book / smart card — front and back scan required.
Passportna_passportBio-data page. Must be a Namibian passport, not expired.
Selfie / LivenessselfieClear face photo, no sunglasses. JPEG or PNG, max 5 MB.

Document Upload API

Use POST /api/verification/upload to upload a document. This endpoint accepts multipart/form-data. Authentication is via Bearer token (see the Authentication page). The merchant's verification_status is updated automatically to submitted once at least one primary document and a selfie have been uploaded.

const FormData = require('form-data');
const fs = require('fs');
const axios = require('axios');

const form = new FormData();
form.append('document_type', 'zm_nrc');        // e.g. zm_nrc, bw_omang, na_national_id
form.append('country', 'ZM');
form.append('file', fs.createReadStream('./nrc_front.jpg'));

const res = await axios.post(
  'https://api.nexapay.net/api/verification/upload',
  form,
  { headers: { ...form.getHeaders(),
      'Authorization': `Bearer ${ACCESS_TOKEN}` } }
);
console.log(res.data);
curl -X POST https://api.nexapay.net/api/verification/upload \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -F "document_type=zm_nrc" \
  -F "country=ZM" \
  -F "file=@/path/to/nrc_front.jpg"

Response

{
  "document_id": "doc_01HY3K...",
  "document_type": "zm_nrc",
  "status": "uploaded",
  "storage_path": "kyc-documents/a1b2c3d4-e5f6-7890-abcd-ef1234567890/zm_nrc/nrc_front.jpg",
  "merchant_verification_status": "submitted",
  "created_at": "2025-06-01T09:30:00Z"
}

Database Schema

Verification state is stored on the profiles table (managed by server.js / Supabase). Document metadata lives in a separate verification_documents table.

-- profiles table (excerpt)
ALTER TABLE profiles
  ADD COLUMN verification_status TEXT
    NOT NULL DEFAULT 'not_started'
    CHECK (verification_status IN
      ('not_started', 'submitted', 'verified', 'rejected'));

-- verification_documents table
CREATE TABLE verification_documents (
  id               UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id          UUID NOT NULL REFERENCES profiles(id),
  document_type    TEXT NOT NULL,
  country          CHAR(2) NOT NULL,
  storage_path     TEXT NOT NULL,
  status           TEXT NOT NULL DEFAULT 'uploaded',
  reviewer_notes   TEXT,
  created_at       TIMESTAMPTZ DEFAULT now()
);

Webhook Events

NexaPay fires webhook events when a merchant's verification status changes. Subscribe to these on the Webhooks page.

EventFired when
kyc.submittedMerchant uploads a complete document set (primary doc + selfie).
kyc.verifiedManual review passes. verification_statusverified.
kyc.rejectedReview fails. Payload includes rejection_reason.
kyc.re_submittedMerchant re-submits after a rejection.

Integration with auth & server.js

The sign-up flow in auth creates a row in the profiles table (via server.js POST /api/auth/profile) with verification_status = 'not_started'. After registration, the Dashboard redirects the merchant to the KYC upload wizard.

Live access requires verification
Only the verified status allows live payment processing. Sandbox-mode requests always succeed regardless of verification_status — set X-NexaPay-Mode: sandbox in your request headers during development.
Refunds & Reversals

Return funds or cancel payouts

Refund any completed pay-in within 30 days. Reverse a payout while it is still in pending state.

Refund vs Reversal
A refund returns funds to a customer who paid via POST /v2/refunds. A reversal cancels a payout before it settles via POST /v2/payouts/{id}/reversal. Reversals are only possible while payout status is pending.

Full Refund

Omit amount to refund the entire original transaction. Refunds are only available within 30 days of the original charge and only when payment status is completed.

// Full refund — omit amount for the full transaction
const res = await fetch('https://api.nexapay.net/v2/refunds', {
  method:  'POST',
  headers: {
    'Authorization': `Bearer ${SK}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    payment_id: 'pay_01HXYZ...',
    reason:     'customer_request',
    reference: 'REF-001'
  })
});
const refund = await res.json();
// { id: "rfd_01...", status: "processing", amount: 100000, currency: "ZMW" }
curl -X POST https://api.nexapay.net/v2/refunds \
  -H "Authorization: Bearer npay_live_sec_..." \
  -H "Content-Type: application/json" \
  -d '{"payment_id":"pay_01HXYZ...","reason":"customer_request","reference":"REF-001"}'

Partial Refund

Include amount in the smallest currency unit to refund a portion. Multiple partial refunds are allowed as long as the cumulative total does not exceed the original transaction amount.

// Partial refund — ZMW 50 of a ZMW 200 payment
await fetch('https://api.nexapay.net/v2/refunds', {
  method:  'POST',
  headers: { 'Authorization': `Bearer ${SK}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    payment_id: 'pay_01HXYZ...',
    amount:     5000,  // ZMW 50.00 in smallest unit
    reason:     'partial_service',
    reference: 'REF-002'
  })
});
curl -X POST https://api.nexapay.net/v2/refunds \
  -H "Authorization: Bearer npay_live_sec_..." \
  -d '{"payment_id":"pay_01HXYZ...","amount":5000,"reason":"partial_service","reference":"REF-002"}'

Check Refund Status

const res = await fetch(
  `https://api.nexapay.net/v2/refunds/${refundId}`,
  { headers: { 'Authorization': `Bearer ${SK}` } }
);
// { id, status: "completed"|"failed"|"processing", amount, created_at }
curl https://api.nexapay.net/v2/refunds/rfd_01HXYZ \
  -H "Authorization: Bearer npay_live_sec_..."

List Refunds for a Payment

const res = await fetch(
  `https://api.nexapay.net/v2/payments/${paymentId}/refunds`,
  { headers: { 'Authorization': `Bearer ${SK}` } }
);
const { data } = await res.json();
// data: [{ id, status, amount, created_at }, ...]
curl https://api.nexapay.net/v2/payments/pay_01HXYZ/refunds \
  -H "Authorization: Bearer npay_live_sec_..."

Reverse a Payout

Pending State Only
A reversal can only be requested while payout status is pending. Once the payout is completed, it cannot be reversed — you would need to initiate a new payout back to your own wallet.
const res = await fetch(
  `https://api.nexapay.net/v2/payouts/${payoutId}/reversal`,
  {
    method:  'POST',
    headers: { 'Authorization': `Bearer ${SK}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ reason: 'wrong_recipient' })
  }
);
// { id: "rev_01...", payout_id, status: "reversed", reversed_at }
curl -X POST https://api.nexapay.net/v2/payouts/po_01HXYZ/reversal \
  -H "Authorization: Bearer npay_live_sec_..." \
  -d '{"reason":"wrong_recipient"}'

Webhook Events

EventWhen fired
refund.completedRefund settled to customer wallet successfully
refund.failedRefund processing failed — retry or contact support
payout.reversedPayout reversal succeeded; funds restored to merchant wallet

Request Parameters — POST /v2/refunds

ParameterTypeDescription
payment_idrequiredstringID of the original completed payment — format pay_01…
amountintegerRefund amount in smallest currency unit. Omit for a full refund.
reasonstringcustomer_request · duplicate · fraud · partial_service
referencestringYour idempotency key. Duplicate references return the existing refund.

Error Codes

CodeMeaning
PAYMENT_NOT_FOUNDNo payment with that ID exists on your account
PAYMENT_NOT_REFUNDABLEPayment is not completed or is older than 30 days
REFUND_EXCEEDS_ORIGINALCumulative refund amount exceeds the original transaction amount
PAYOUT_NOT_REVERSIBLEPayout has already settled and cannot be reversed
DUPLICATE_REFERENCEA refund with this reference was already processed (idempotent response returned)
Transaction Charges

Fees per country — pay-in & payout

All charges are deducted from your merchant wallet. NexaPay absorbs telco pass-through fees — you pay one flat platform fee per transaction.

How fees work
Fees are calculated as a percentage of the transaction amount and deducted from your NexaPay wallet balance at the time of the transaction. Minimum and maximum fee caps apply per transaction. All amounts shown are in the local currency of each country.

Charges by Country

You
What does a pay-in cost in Zambia?
N
Pay-in · ZMW
Platform fee1.5% of amount Minimum feeZMW 1.50 Maximum feeZMW 150.00 Telco pass-through0% (absorbed)
Example: ZMW 1,000 charge → fee ZMW 15.00
Payouts
You
And payout fees?
N
Payout · ZMW
Platform fee1.0% of amount Minimum feeZMW 2.00 Maximum feeZMW 100.00 Telco pass-through0% (absorbed)
Example: ZMW 2,000 payout → fee ZMW 20.00
Settlement
You
When do funds reach my account?
N
Settlement · Zambia
Merchant walletT+0 (instant) Bank transferT+1 business day FX conversion (USD)+0.5% conversion fee
You
What does a pay-in cost in Zimbabwe?
N
Pay-in · ZiG
Platform fee2.0% of amount Minimum feeZiG 1.00 Maximum feeZiG 100.00 Telco pass-through0% (absorbed)
Example: ZiG 500 charge → fee ZiG 10.00

Pay-in · USD
Platform fee2.0% of amount Minimum feeUSD 0.10 Maximum feeUSD 10.00
Example: USD 50 charge → fee USD 1.00
Payouts
You
What about payouts?
N
Payout · ZiG / USD
Platform fee1.5% of amount Minimum fee (ZiG)ZiG 1.50 Minimum fee (USD)USD 0.15 Telco pass-through0% (absorbed)
Example: USD 100 payout → fee USD 1.50
Settlement
You
When do funds settle?
N
Settlement · Zimbabwe
Merchant walletT+0 (instant) Bank transferT+1 business day ZiG ↔ USD conversion+0.5% conversion fee
You
What does a pay-in cost in Botswana?
N
Pay-in · BWP
Platform fee1.5% of amount Minimum feeBWP 0.50 Maximum feeBWP 50.00 Telco pass-through0% (absorbed)
Example: BWP 200 charge → fee BWP 3.00
Payouts
You
What about payouts?
N
Payout · BWP
Platform fee1.0% of amount Minimum feeBWP 1.00 Maximum feeBWP 80.00 Telco pass-through0% (absorbed)
Example: BWP 500 payout → fee BWP 5.00
Settlement
You
When does money reach my wallet?
N
Settlement · Botswana
Merchant walletT+0 (instant) Bank transferT+1 business day FX conversion+0.5% conversion fee
You
What does a pay-in cost in Namibia?
N
Pay-in · NAD
Platform fee1.5% of amount Minimum feeNAD 1.00 Maximum feeNAD 75.00 Telco pass-through0% (absorbed)
Example: NAD 400 charge → fee NAD 6.00
Payouts
You
And payout fees?
N
Payout · NAD
Platform fee1.0% of amount Minimum feeNAD 2.00 Maximum feeNAD 80.00 Telco pass-through0% (absorbed)
Example: NAD 1,000 payout → fee NAD 10.00
Settlement
You
When does money reach my account?
N
Settlement · Namibia
Merchant walletT+0 (instant) Bank transferT+1 business day FX conversion+0.5% conversion fee

At-a-Glance Summary

CountryCurrencyPay-in feePayout feeMin / Max fee
ZM ZambiaZMW1.5%1.0%ZMW 1.50 / ZMW 150
ZW ZimbabweZiG / USD2.0%1.5%USD 0.10 / USD 10
BW BotswanaBWP1.5%1.0%BWP 0.50 / BWP 50
NA NamibiaNAD1.5%1.0%NAD 1.00 / NAD 75