Build with Elicate Pay
Everything you need to accept mobile money payments in Zambia. One API, all networks, real-time webhooks.
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
Your server sends a charge request to Elicate Pay
Elicate Pay forwards the charge to Flutterwave
If Zambia mobile money, customer is redirected to Flutterwave verification page (USSD PIN/captcha)
Customer receives a USSD prompt and approves on their phone
Flutterwave sends a callback → Elicate Pay updates the status
Elicate Pay sends a webhook to your server with the result
Base URLs#
https://elicatepay.vercel.apphttp://localhost:3000All 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-sideFor charge, transactions, and payment links. Set in the Authorization header.
Authorization: Bearer ep_test_sk_...Public API Key
Frontend-safeFor the checkout/pay endpoint only. Sent in the request body, not headers.
{ "public_key": "ep_test_pk_..." }API Key Prefixes#
| Prefix | Mode | Type | Use |
|---|---|---|---|
ep_test_pk_ | Test | Public | Frontend checkout |
ep_test_sk_ | Test | Secret | Server-side API calls |
ep_live_pk_ | Live | Public | Frontend checkout (real money) |
ep_live_sk_ | Live | Secret | Server-side API calls (real money) |
Charge a Customer#
Initiate a mobile money payment from your backend.
/api/v1/payments/chargeSecret Key| Field | Type | Required | Notes |
|---|---|---|---|
amount | number | Yes | Amount in ZMW, must be > 0 |
phone | string | Yes | Customer's phone number |
network | string | Yes | MTN, AIRTEL, or ZAMTEL |
currency | string | No | Defaults to ZMW |
reference | string | No | Your order/invoice ID. Auto-generated if omitted |
customer_name | string | No | Defaults to "Customer" |
{
"amount": 50.00,
"phone": "0962000000",
"network": "MTN",
"currency": "ZMW",
"reference": "ORDER-12345",
"customer_name": "John Banda"
}{
"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:
- Redirect the customer to
redirect_url. - 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.
- Keep polling running continuously until the status changes to
successorfailed. If the user refreshes the page, your app should resume polling where it left off using the savedtransaction_id.
| Status | Condition |
|---|---|
| 400 | Missing required fields |
| 400 | Invalid network |
| 401 | Invalid/missing API key |
| 401 | Used public key instead of secret |
| 403 | Merchant suspended/rejected |
| 403 | Live key but account not approved |
| 502 | Flutterwave charge failed |
Checkout Pay (Frontend-Safe)#
Public endpoint for customer-facing checkout pages. No secret key needed. Two flows:
/api/v1/checkout/payPublic Key or SlugFlow A: Payment Link Slug
{
"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
{
"public_key": "ep_test_pk_xxx...",
"customer_name": "Jane Mwale",
"phone": "0973000000",
"network": "MTN",
"amount": 100.00,
"description": "Widget purchase",
"reference": "CART-789"
}| Field | Type | Required | Notes |
|---|---|---|---|
link_slug | string | No | Required for Flow A |
public_key | string | No | Required for Flow B |
customer_name | string | Yes | |
phone | string | Yes | |
network | string | Yes | MTN, AIRTEL, or ZAMTEL |
amount | number | No | Required for flexible links and Flow B |
description | string | No | Flow B only |
reference | string | No | Auto-generated if omitted |
{
"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.
/api/v1/checkout/status/{transaction_id}None{
"transaction_id": "EP-M4K2F9-A1B2",
"status": "pending",
"amount": 100.00,
"currency": "ZMW",
"reference": "ORDER-12345"
}status values: pending, success, failedGet Transaction Details#
Returns full details including fees. Requires secret key. Only returns transactions belonging to your merchant account.
/api/v1/payments/{transaction_id}Secret Key{
"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
}Real-Time Status Stream (SSE)#
Server-Sent Events that push status updates. Polls every 2s. Closes on terminal status or after 55 seconds.
/api/v1/payments/{transaction_id}/streamSecret Key| Event | Meaning |
|---|---|
payment.pending | Waiting for customer approval |
payment.success | Payment completed |
payment.failed | Payment failed or declined |
timeout | 55s timeout — reconnect if needed |
error | Transaction deleted or poll error |
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,...}
}List Payment Links#
/api/v1/payment-linksSecret Key{
"payment_links": [
{
"id": "abc123firebaseDocId",
"name": "Pro Plan Subscription",
"type": "fixed",
"amount": 299,
"min_amount": 0,
"description": "Monthly subscription",
"redirect_url": "https://mysite.com/thanks",
"active": true,
"slug": "a1b2c3d4",
"url": "https://elicatepay.vercel.app/pay/a1b2c3d4",
"created_at": 1710000000000
}
],
"total": 1
}Create a Payment Link#
/api/v1/payment-linksSecret Key| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Display name for the link |
type | string | Yes | "fixed" or "flexible" |
amount | number | No | Required if type is fixed |
min_amount | number | No | Minimum for flexible links. Default 0 |
description | string | No | |
redirect_url | string | No | URL to redirect after payment |
{
"name": "Donation",
"type": "flexible",
"min_amount": 10,
"description": "Support our cause",
"redirect_url": "https://mysite.com/thankyou"
}{
"id": "abc123firebaseDocId",
"name": "Donation",
"type": "flexible",
"amount": 0,
"min_amount": 10,
"description": "Support our cause",
"redirect_url": "https://mysite.com/thankyou",
"active": true,
"slug": "x9y8z7w6",
"url": "https://elicatepay.vercel.app/pay/x9y8z7w6",
"created_at": 1710000000000
}Get a Single Payment Link#
/api/v1/payment-links/{linkId}Secret KeyReturns the same shape as a single item from the list endpoint, plus updated_at.
Update a Payment Link#
/api/v1/payment-links/{linkId}Secret KeyAll fields are optional — include only what you want to change.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | No | |
type | string | No | "fixed" or "flexible" |
amount | number | No | |
min_amount | number | No | |
description | string | No | |
redirect_url | string | No | |
active | boolean | No | Set false to disable |
{
"name": "Updated Name",
"active": false
}Webhooks#
When a payment completes (success or fail), Elicate Pay sends a POST webhook to your configured URL.
Payload Format#
{
"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"
}
}| Event | Meaning |
|---|---|
payment.success | Payment approved and completed |
payment.failed | Payment failed or declined |
payment.test | Test webhook from the test endpoint |
Signature Verification#
Every webhook includes an HMAC-SHA256 signature in the X-ElicatePay-Signature header.
How to verify
Get the raw request body as a string
Compute HMAC-SHA256 of that string using your webhook secret
Compare with the X-ElicatePay-Signature header (use constant-time comparison)
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:
Test Webhook Delivery#
Send a test webhook to any URL. No auth required.
/api/v1/webhooks/testNone{
"url": "https://your-server.com/webhook",
"secret": "your-webhook-secret"
}{
"status": "delivered",
"httpStatus": 200
}Handling Redirect Mode (Iframe Embed)#
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.<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#
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#
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", 200PHP#
<?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#
'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#
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"}'curl https://elicatepay.vercel.app/api/v1/checkout/status/EP-M4K2F9-A1B2curl -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}'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#
| Status | Meaning |
|---|---|
| 200 | Success |
| 201 | Created (payment link) |
| 400 | Bad request — missing or invalid fields |
| 401 | Auth failed — invalid/missing key or token |
| 403 | Forbidden — suspended, not approved, or not admin |
| 404 | Resource not found |
| 409 | Conflict — resource already exists |
| 410 | Gone — inactive or deprecated |
| 502 | Bad gateway — Flutterwave call failed |
| 503 | Service 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.