API Reference v1

Build with Elicate Pay

Everything you need to accept mobile money payments and send merchant payouts in Zambia. One API, all networks, real-time webhooks.

Simple REST API
HMAC Webhooks
Test & Live Modes
Quickstart — charge a customer
curl -X POST https://elicatepay.vercel.app/api/v1/payments/charge \
  -H "Authorization: Bearer ep_test_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"amount":50,"phone":"0962000000","network":"MTN"}'

Overview#

Elicate Pay is a Zambian mobile money payment gateway. It lets merchants accept payments from MTN, Airtel, and Zamtel wallets. Currency is always ZMW (Zambian Kwacha).

How a payment works

1

Your server sends a charge request to Elicate Pay

2

Elicate Pay forwards the charge to Flutterwave

3

If Zambia mobile money, customer is redirected to Flutterwave verification page (USSD PIN/captcha)

4

Customer receives a USSD prompt and approves on their phone

5

Flutterwave sends a callback → Elicate Pay updates the status

6

Elicate Pay sends a webhook to your server with the result

Base URLs#

Production
https://elicatepay.vercel.app
Local Dev
http://localhost:3000

All endpoints are prefixed with /api/v1/

Authentication#

Elicate Pay uses 2 auth methods depending on the endpoint. Each endpoint section below tells you which one to use.

Secret API Key

Server-side

For charges, payouts, transactions, and payment links. Set in the Authorization header.

Authorization: Bearer ep_test_sk_...

Public API Key

Frontend-safe

For the checkout/pay endpoint only. Sent in the request body, not headers.

{ "public_key": "ep_test_pk_..." }

API Key Prefixes#

PrefixModeTypeUse
ep_test_pk_TestPublicFrontend checkout
ep_test_sk_TestSecretServer-side API calls
ep_live_pk_LivePublicFrontend checkout (real money)
ep_live_sk_LiveSecretServer-side API calls (real money)
Test mode calls Flutterwave sandbox — no real money moves. Live mode calls Flutterwave production — real money is charged. Same endpoints, same format. Only the key prefix changes. For test transactions made with test API keys, use 123456when an OTP is required.

Charge a Customer#

Initiate a mobile money payment from your backend.

POST/api/v1/payments/chargeSecret Key
FieldTypeRequiredNotes
amountnumber YesAmount in ZMW, must be > 0
phonestring YesCustomer's phone number
networkstring YesMTN, AIRTEL, or ZAMTEL
currencystringNoDefaults to ZMW
referencestringNoYour order/invoice ID. Auto-generated if omitted
customer_namestringNoDefaults to "Customer"
RequestJSON
{
  "amount": 50.00,
  "phone": "0962000000",
  "network": "MTN",
  "currency": "ZMW",
  "reference": "ORDER-12345",
  "customer_name": "John Banda"
}
ResponseJSON
{
  "transaction_id": "EP-M4K2F9-A1B2",
  "status": "pending",
  "reference": "ORDER-12345",
  "meta": {
    "authorization": {
      "mode": "redirect",
      "redirect_url": "https://flutterwave.com/verify/redirect/xyz..."
    },
    "message": "Customer must complete verification. If redirect mode, open the redirect_url in an iframe or new tab."
  }
}

A pending response means the charge was sent to the customer's phone. Depending on the transaction route, the customer must either complete authentication via the redirect_url, or they may receive an OTP through WhatsApp to authorize the payment.

When testing with test API keys, enter 123456 if the sandbox flow asks for an OTP.

Recommended Flow:
  1. Redirect the customer to redirect_url.
  2. Once they return to your site (or while they are on an awaiting payment screen), poll the Get Transaction Details endpoint every 3 seconds. This ensures you catch the success status immediately, even if the webhook gets delayed.
  3. Keep polling running continuously until the status changes to success (or test success in test mode) or failed. If the user refreshes the page, your app should resume polling where it left off using the saved transaction_id.
StatusCondition
400Missing required fields
400Invalid network
401Invalid/missing API key
401Used public key instead of secret
403Merchant suspended/rejected
403Live key but account not approved
502Flutterwave charge failed

Platform Enhancements and Upcoming Improvements#

Elicate Pay continuously evolves its payment infrastructure to provide the most seamless payment experience possible for merchants and customers.

The current OTP verification process is part of the existing mobile money authentication flow and helps ensure secure transaction authorization. Depending on the transaction route, OTP verification may currently be delivered through WhatsApp.

Our engineering team and technology partners are actively working on enhancements to the collection experience. Future updates are expected to streamline the authorization process and reduce friction for customers during payment confirmation.

These improvements may include expanded authorization options, improved delivery mechanisms, and more direct transaction approval experiences where supported by participating mobile money networks.

As these enhancements are rolled out, merchants may notice fewer authentication steps and a smoother customer payment journey.

Note: Until these updates are fully deployed, developers should ensure that customers are aware of the current OTP verification requirements described above.

What this means for developers

  • The current OTP verification flow is fully supported and production-ready.
  • No action is required from existing integrations.
  • Future improvements will be introduced in a backward-compatible manner whenever possible.
  • Elicate Pay remains committed to reducing payment friction while maintaining security and transaction reliability.

Update notifications

Elicate Pay will provide official updates through multiple channels, including the developer changelog and direct email notifications. When significant enhancements to the OTP verification flow or related payment processes are released, merchants will be notified by email.

Keep your developer account contact email up to date to receive important platform announcements promptly.

Checkout Pay (Frontend-Safe)#

Public endpoint for customer-facing checkout pages. No secret key needed. Two flows:

POST/api/v1/checkout/payPublic Key or Slug

Flow A: Payment Link Slug

RequestJSON
{
  "link_slug": "a1b2c3d4",
  "customer_name": "Jane Mwale",
  "phone": "0973000000",
  "network": "AIRTEL",
  "amount": 75.00
}

amount is only required for flexible links. Fixed links use their preset amount.

Flow B: Public Key

RequestJSON
{
  "public_key": "ep_test_pk_xxx...",
  "customer_name": "Jane Mwale",
  "phone": "0973000000",
  "network": "MTN",
  "amount": 100.00,
  "description": "Widget purchase",
  "reference": "CART-789"
}
FieldTypeRequiredNotes
link_slugstringNoRequired for Flow A
public_keystringNoRequired for Flow B
customer_namestring Yes
phonestring Yes
networkstring YesMTN, AIRTEL, or ZAMTEL
amountnumberNoRequired for flexible links and Flow B
descriptionstringNoFlow B only
referencestringNoAuto-generated if omitted
ResponseJSON
{
  "transaction_id": "EP-M4K2F9-A1B2",
  "status": "redirect",
  "reference": "CART-789",
  "meta": {
    "authorization": {
      "mode": "redirect",
      "redirect_url": "https://flutterwave.com/verify/redirect/xyz..."
    },
    "message": "Customer must complete verification. If redirect mode, open the redirect_url in an iframe or new tab."
  }
}

Check Transaction Status#

No auth needed. Safe to call from frontend.

GET/api/v1/checkout/status/{transaction_id}None
ResponseJSON
{
  "transaction_id": "EP-M4K2F9-A1B2",
  "status": "pending",
  "amount": 100.00,
  "currency": "ZMW",
  "reference": "ORDER-12345"
}
Possible status values: pending, success, test success (in test mode), failed

Get Transaction Details#

Returns full details including fees. Requires secret key. Only returns transactions belonging to your merchant account.

Best Practice: After initiating a charge and redirecting the customer, you should run a background task on your frontend or server that polls this endpoint every 3 seconds. Since this endpoint automatically performs on-the-fly reconciliation with Flutterwave if the status is pending, polling this is highly reliable even if webhooks fail or the user drops off their internet connection temporarily.
GET/api/v1/payments/{transaction_id}Secret Key
ResponseJSON
{
  "transaction_id": "EP-M4K2F9-A1B2",
  "status": "success",
  "amount": 100.00,
  "fee": 2.60,
  "net_amount": 97.40,
  "currency": "ZMW",
  "reference": "ORDER-12345",
  "customer_name": "John Banda",
  "customer_phone": "0962000000",
  "network": "MTN",
  "created_at": 1710000000000,
  "updated_at": 1710000060000
}
Fees: 2.6% on successful transactions (0 on failed). Timestamps are Unix milliseconds.

Real-Time Status Stream (SSE)#

Server-Sent Events that push status updates. Polls every 2s. Closes on terminal status or after 55 seconds.

GET/api/v1/payments/{transaction_id}/streamSecret Key
EventMeaning
payment.pendingWaiting for customer approval
payment.successPayment completed
payment.failedPayment failed or declined
timeout55s timeout — reconnect if needed
errorTransaction deleted or poll error
Example — fetch with ReadableStreamJavaScript
const response = await fetch(
  'https://elicatepay.vercel.app/api/v1/payments/EP-XXX/stream',
  { headers: { 'Authorization': 'Bearer ep_test_sk_...' } }
);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  console.log(decoder.decode(value));
  // event: payment.success
  // data: {"transaction_id":"EP-XXX","status":"success","amount":100,...}
}

Payouts#

Send money from your available Elicate Pay merchant balance to a bank account or mobile money wallet. Payouts require a secret key and are never initiated from frontend code.

Create a Payout#

POST/api/v1/payoutsSecret Key
FieldTypeRequiredNotes
amountnumber YesAmount in ZMW. Must be greater than 0 and within available balance.
currencystringNoDefaults to ZMW. Only ZMW is supported for now.
account_bankstring YesFlutterwave bank/operator code. For ZMW mobile money, use "MPS".
account_numberstring YesBank account number or mobile money wallet number.
beneficiary_namestring YesRecipient full name.
referencestringNoYour unique payout reference. Auto-generated if omitted.
narrationstringNoStatement/narration text.
RequestJSON
{
  "amount": 25,
  "currency": "ZMW",
  "account_bank": "MPS",
  "account_number": "260971234567",
  "beneficiary_name": "Mary Tembo",
  "reference": "PAYOUT-001",
  "narration": "Merchant settlement"
}
ResponseJSON
{
  "payout_id": "PO-M4K2F9-A1B2",
  "status": "pending",
  "reference": "PAYOUT-001",
  "amount": 25,
  "currency": "ZMW",
  "available_balance_before_payout": 97.50,
  "flutterwave_transfer_id": 8773850,
  "message": "Transfer Queued Successfully"
}
If the merchant does not have enough available balance, the API returns 402 Insufficient merchant balance. Available balance is calculated on the server from successful payment net amounts minus non-failed payouts.
Flutterwave transfers may require IP whitelisting on the Flutterwave account. If Flutterwave returns FLUTTERWAVE_IP_WHITELIST_REQUIRED, whitelist the deployed Elicate Pay server IP before retrying live payouts.

Payout Fees#

When initiating a payout, a flat transfer fee is deducted from the merchant's available balance in addition to the requested payout amount. The payout recipient always receives the full requested amount.

Strategic Withdrawals: Because transfer fees are charged as a flat rate per transaction (and not a percentage), it is highly recommended that merchants consolidate their funds and make fewer, larger withdrawals rather than many small withdrawals. This prevents the flat fee from eating into profit margins.
ScenarioCalculation
Requested AmountE.g., 100.00 ZMW
Transfer FeeDynamic flat fee from Flutterwave (e.g., 15.00 ZMW)
Total DeductionAmount + Fee (e.g., 115.00 ZMW)

Get Payout Status#

Returns the payout record for the authenticated merchant. Pending payouts are reconciled with Flutterwave when possible.

GET/api/v1/payouts/{payout_id}Secret Key
ResponseJSON
{
  "payout_id": "PO-M4K2F9-A1B2",
  "status": "pending",
  "amount": 25,
  "fee": 0.75,
  "currency": "ZMW",
  "reference": "PAYOUT-001",
  "account_bank": "MPS",
  "account_number": "260971234567",
  "beneficiary_name": "Mary Tembo",
  "flutterwave_transfer_id": 8773850,
  "flutterwave_status": "NEW",
  "created_at": 1710000000000,
  "updated_at": 1710000060000
}
Possible status values: pending, success, failed

Balance Security#

There is no client-editable balance field. The payout endpoint uses the server Admin SDK to calculate balance from the transaction and payout ledgers, and Firestore client rules block all writes to both ledgers.
  • Merchants can read their own payouts, but cannot create or edit payout documents directly in Firestore.
  • Transactions, payout statuses, fees, and net amounts are server-write-only.
  • API-created payouts also appear in the merchant dashboard payout history for audit visibility.
  • Merchant profile updates are limited to safe profile fields, not financial or approval fields.

Webhooks#

When a payment or payout completes (success or fail), Elicate Pay sends a POST webhook to your configured URL.

Payload Format#

Webhook payloadJSON
{
  "event": "payment.success",
  "data": {
    "transaction_id": "EP-M4K2F9-A1B2",
    "reference": "ORDER-12345",
    "amount": 100.00,
    "fee": 2.50,
    "net_amount": 97.50,
    "currency": "ZMW",
    "customer_name": "John Banda",
    "customer_phone": "0962000000",
    "network": "MTN",
    "status": "success"
  }
}
The status field inside the payload data will be test success for test transactions and success for live transactions.
{
  "event": "payout.success",
  "data": {
    "payout_id": "PO-M4K2F9-A1B2",
    "reference": "PAYOUT-001",
    "amount": 25,
    "fee": 0.75,
    "currency": "ZMW",
    "account_bank": "MPS",
    "account_number": "260971234567",
    "beneficiary_name": "Mary Tembo",
    "flutterwave_transfer_id": 8773850,
    "status": "success"
  }
}
EventMeaning
payment.successPayment approved and completed
payment.failedPayment failed or declined
payout.successPayout completed successfully
payout.failedPayout failed
payment.testTest webhook from the test endpoint

Signature Verification#

Every webhook includes an HMAC-SHA256 signature in the X-ElicatePay-Signature header.

How to verify

1

Get the raw request body as a string

2

Compute HMAC-SHA256 of that string using your webhook secret

3

Compare with the X-ElicatePay-Signature header (use constant-time comparison)

Node.js verificationJavaScript
const crypto = require('crypto');

function verifyWebhook(rawBody, signature, secret) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// Express middleware
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-elicatepay-signature'];
  const rawBody = req.body.toString();

  if (!verifyWebhook(rawBody, sig, 'your-webhook-secret')) {
    return res.status(401).send('Invalid signature');
  }

  const { event, data } = JSON.parse(rawBody);
  if (event === 'payment.success') {
    // Fulfill the order
  }
  res.status(200).send('OK');
});
import hmac, hashlib

def verify_webhook(raw_body: str, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(), raw_body.encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

# Flask
@app.route('/webhook', methods=['POST'])
def webhook():
    sig = request.headers.get('X-ElicatePay-Signature', '')
    raw = request.get_data(as_text=True)
    if not verify_webhook(raw, sig, 'your-webhook-secret'):
        return 'Invalid signature', 401
    payload = request.get_json()
    if payload['event'] == 'payment.success':
        pass  # Fulfill the order
    return 'OK', 200
$rawBody = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_ELICATEPAY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, 'your-webhook-secret');

if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit('Invalid signature');
}

$payload = json_decode($rawBody, true);
if ($payload['event'] === 'payment.success') {
    // Fulfill the order
}
http_response_code(200);
echo 'OK';

Retry Policy#

If your server returns a non-2xx response or is unreachable:

Attempt 1
Immediate
Attempt 2
1 minute
Attempt 3
5 minutes
Attempt 4
30 minutes
After 4 failed attempts, the webhook is marked as permanent_failure. Your endpoint must return HTTP 200-299 within 10 seconds and be idempotent (the same event may arrive more than once).

Test Webhook Delivery#

Send a test webhook to any URL. No auth required.

POST/api/v1/webhooks/testNone
RequestJSON
{
  "url": "https://your-server.com/webhook",
  "secret": "your-webhook-secret"
}
ResponseJSON
{
  "status": "delivered",
  "httpStatus": 200
}

Handling Redirect Mode (Iframe Embed)#

For Zambia mobile money, Flutterwave returns mode: "redirect" and a redirect_url. You must open this URL in an iframe or a new tab for the customer to complete verification. This step cannot be skipped.
Embed redirect_url in iframeTSX
<iframe src={redirectUrl} width="100%" height="420" style={{ borderRadius: 12, border: '1px solid #eee' }} />

If the iframe is blocked, show a button to open the redirect_url in a new tab.

Node.js / Express#

server.jsJavaScript
const express = require('express');
const crypto = require('crypto');
const app = express();

const SECRET_KEY = 'ep_test_sk_your_key_here';
const BASE = 'https://elicatepay.vercel.app';
const WH_SECRET = 'your-webhook-secret';

// 1. Charge a customer
app.post('/pay', express.json(), async (req, res) => {
  const { phone, network, amount, name } = req.body;

  const response = await fetch(`${BASE}/api/v1/payments/charge`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${SECRET_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      amount, phone, network,
      customer_name: name,
      reference: `ORDER-${Date.now()}`,
    }),
  });

  const data = await response.json();
  if (!response.ok) return res.status(response.status).json({ error: data.error });

  // Save data.transaction_id with your order in your database
  res.json({ message: 'Check your phone to approve', transaction_id: data.transaction_id });
});

// 2. Receive webhooks
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = req.headers['x-elicatepay-signature'];
  const raw = req.body.toString();
  const expected = crypto.createHmac('sha256', WH_SECRET).update(raw).digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(sig || ''), Buffer.from(expected))) {
    return res.status(401).send('Invalid signature');
  }

  const { event, data } = JSON.parse(raw);
  if (event === 'payment.success') {
    console.log(`Payment ${data.transaction_id} succeeded! Net: ${data.net_amount} ZMW`);
    // Mark order as paid in your database
  }
  res.status(200).send('OK');
});

app.listen(3001);

Python / Flask#

app.pyPython
import requests, hmac, hashlib
from flask import Flask, request, jsonify

app = Flask(__name__)

SECRET_KEY = "ep_test_sk_your_key_here"
BASE = "https://elicatepay.vercel.app"
WH_SECRET = "your-webhook-secret"

@app.route("/pay", methods=["POST"])
def pay():
    data = request.get_json()
    resp = requests.post(
        f"{BASE}/api/v1/payments/charge",
        headers={"Authorization": f"Bearer {SECRET_KEY}", "Content-Type": "application/json"},
        json={
            "amount": data["amount"],
            "phone": data["phone"],
            "network": data["network"],
            "customer_name": data.get("name", "Customer"),
        },
    )
    result = resp.json()
    if resp.status_code != 200:
        return jsonify({"error": result.get("error")}), resp.status_code
    return jsonify({"message": "Check your phone", "transaction_id": result["transaction_id"]})

@app.route("/webhook", methods=["POST"])
def webhook():
    sig = request.headers.get("X-ElicatePay-Signature", "")
    raw = request.get_data(as_text=True)
    expected = hmac.new(WH_SECRET.encode(), raw.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(sig, expected):
        return "Invalid signature", 401
    payload = request.get_json()
    if payload["event"] == "payment.success":
        print(f"Payment {payload['data']['transaction_id']} succeeded!")
    return "OK", 200

PHP#

charge.phpPHP
<?php
$SECRET_KEY = 'ep_test_sk_your_key_here';
$BASE = 'https://elicatepay.vercel.app';

function charge(float $amount, string $phone, string $network): array {
    global $SECRET_KEY, $BASE;
    $ch = curl_init("$BASE/api/v1/payments/charge");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer $SECRET_KEY",
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS => json_encode([
            'amount' => $amount,
            'phone' => $phone,
            'network' => $network,
        ]),
    ]);
    $res = curl_exec($ch);
    curl_close($ch);
    return json_decode($res, true);
}

// webhook.php — verify and handle
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_ELICATEPAY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $raw, 'your-webhook-secret');
if (!hash_equals($expected, $sig)) { http_response_code(401); exit('Bad sig'); }
$payload = json_decode($raw, true);
if ($payload['event'] === 'payment.success') {
    // Mark order as paid
}
http_response_code(200);

Next.js / React — Frontend Checkout#

CheckoutForm.tsxTSX
'use client';
import { useState } from 'react';

export default function CheckoutForm({ publicKey }: { publicKey: string }) {
  const [phone, setPhone] = useState('');
  const [network, setNetwork] = useState('MTN');
  const [status, setStatus] = useState<'idle'|'loading'|'pending'|'success'|'failed'>('idle');

  async function handlePay() {
    setStatus('loading');
    const res = await fetch('https://elicatepay.vercel.app/api/v1/checkout/pay', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        public_key: publicKey,
        customer_name: 'Customer',
        phone, network,
        amount: 100,
      }),
    });
    const data = await res.json();
    if (!res.ok) { alert(data.error); setStatus('idle'); return; }

    setStatus('pending');
    // Poll for result
    const poll = setInterval(async () => {
      const s = await fetch(`https://elicatepay.vercel.app/api/v1/checkout/status/${data.transaction_id}`);
      const st = await s.json();
      if (st.status === 'success') { clearInterval(poll); setStatus('success'); }
      if (st.status === 'failed')  { clearInterval(poll); setStatus('failed'); }
    }, 3000);
    setTimeout(() => clearInterval(poll), 120000);
  }

  return (
    <div>
      <input placeholder="Phone" value={phone} onChange={e => setPhone(e.target.value)} />
      <select value={network} onChange={e => setNetwork(e.target.value)}>
        <option>MTN</option><option>AIRTEL</option><option>ZAMTEL</option>
      </select>
      <button onClick={handlePay} disabled={status==='loading'||status==='pending'}>
        {status==='pending' ? 'Waiting...' : 'Pay K100'}
      </button>
      {status==='success' && <p>Payment successful!</p>}
      {status==='failed' && <p>Payment failed.</p>}
    </div>
  );
}

cURL#

Chargebash
curl -X POST https://elicatepay.vercel.app/api/v1/payments/charge \
  -H "Authorization: Bearer ep_test_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"amount":50,"phone":"0962000000","network":"MTN","customer_name":"John"}'
Check status (no auth)bash
curl https://elicatepay.vercel.app/api/v1/checkout/status/EP-M4K2F9-A1B2
Create payment linkbash
curl -X POST https://elicatepay.vercel.app/api/v1/payment-links \
  -H "Authorization: Bearer ep_test_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"name":"Product","type":"fixed","amount":250}'
Create payoutbash
curl -X POST https://elicatepay.vercel.app/api/v1/payouts \
  -H "Authorization: Bearer ep_test_sk_your_key" \
  -H "Content-Type: application/json" \
  -d '{"amount":25,"currency":"ZMW","account_bank":"MPS","account_number":"260971234567","beneficiary_name":"Mary Tembo","reference":"PAYOUT-001"}'
Test webhookbash
curl -X POST https://elicatepay.vercel.app/api/v1/webhooks/test \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-server.com/webhook","secret":"your-secret"}'

Error Code Reference#

StatusMeaning
200Success
201Created (payment link)
400Bad request — missing or invalid fields
401Auth failed — invalid/missing key or token
402Insufficient merchant balance for payout
403Forbidden — suspended, not approved, or not admin
404Resource not found
409Conflict — resource already exists
410Gone — inactive or deprecated
502Bad gateway — Flutterwave call failed
503Service unavailable — server config error

All errors return:

{ "error": "Human-readable error message" }

Important Notes

  • Currency: Always ZMW. Do not send other currencies.
  • Networks: Only MTN, AIRTEL, ZAMTEL. Send uppercase.
  • Phone: Zambian format, e.g. 0962000000
  • Fees: 2.6% on successful transactions.
  • Timestamps: Unix milliseconds (not seconds).
  • Transaction IDs: Format EP-XXXXXX-XXXX
  • Test vs Live: Same endpoints, same format. Only the key prefix changes.