API Reference v1

Build with Elicate Pay

Everything you need to accept mobile money payments 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 charge, 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.

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. The customer must first complete authentication via the redirect_url, then approve the payment on their phone via the USSD prompt.

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 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

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, 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.50,
  "net_amount": 97.50,
  "currency": "ZMW",
  "reference": "ORDER-12345",
  "customer_name": "John Banda",
  "customer_phone": "0962000000",
  "network": "MTN",
  "created_at": 1710000000000,
  "updated_at": 1710000060000
}
Fees: 2.5% 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,...}
}

Webhooks#

When a payment 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"
  }
}
EventMeaning
payment.successPayment approved and completed
payment.failedPayment failed or declined
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}'
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
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.5% 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.