This guide adds Airtel Money payments to a Node.js app with Airtel’s Open API. The customer gets a USSD prompt on their phone and approves the payment with their PIN. By the end you’ll have:
- an
airtel.jsclient with token caching, - an Express server that starts payments and receives callbacks,
- status checks that confirm every callback, and resolve
TIPandTApayments, - a test that runs the whole flow without calling Airtel.
It uses Node’s built-in fetch (Node 18+) and Express. Every code block was run in a fresh project, and the test at the end passes.
Airtel’s public documentation is thinner than MTN’s or Safaricom’s, and some details vary by country. Where this guide relies on behaviour reported by developers rather than documented by Airtel, it says so, and the code is written to stay safe either way.
Before you start
- Create an application in the Airtel developer portal (developers.airtel.africa) for your country, with the Collection product.
- Copy the client ID and client secret from Key Management. UAT (sandbox) and production have separate credentials, and production needs Airtel to approve your business.
- Register your callback URL on the application. It must be public HTTPS. For local development, use a tunnel such as
cloudflaredorngrok. - Install Express:
npm init -y && npm pkg set type=module
npm install express
Create a .env file (load it with node --env-file=.env) and never commit it:
AIRTEL_ENV=uat
AIRTEL_CLIENT_ID=your-client-id
AIRTEL_CLIENT_SECRET=your-client-secret
AIRTEL_COUNTRY=UG
AIRTEL_CURRENCY=UGX
The Airtel client
The client handles the three calls you need: get a token, request a payment, and check its status. Every call carries the X-Country and X-Currency headers for your market.
// airtel.js
const env = process.env;
const baseUrl = () => (env.AIRTEL_ENV === 'production' ? 'https://openapi.airtel.africa' : 'https://openapiuat.airtel.africa');
let cachedToken = null;
let tokenExpiresAt = 0;
export async function getToken() {
if (cachedToken && Date.now() < tokenExpiresAt - 60_000) return cachedToken;
const res = await fetch(`${baseUrl()}/auth/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: '*/*' },
body: JSON.stringify({
client_id: env.AIRTEL_CLIENT_ID,
client_secret: env.AIRTEL_CLIENT_SECRET,
grant_type: 'client_credentials',
}),
});
if (!res.ok) throw new Error(`Airtel token failed: ${res.status} ${await res.text()}`);
const data = await res.json();
cachedToken = data.access_token;
tokenExpiresAt = Date.now() + Number(data.expires_in ?? 3600) * 1000;
return cachedToken;
}
async function headers() {
return {
'Content-Type': 'application/json',
Accept: '*/*',
'X-Country': env.AIRTEL_COUNTRY,
'X-Currency': env.AIRTEL_CURRENCY,
Authorization: `Bearer ${await getToken()}`,
};
}
/** Airtel wants the number without the country code or leading 0: "0752 123 456" -> "752123456". */
export function airtelMsisdn(raw, countryCode = '256') {
let digits = String(raw).replace(/\D/g, '');
if (digits.startsWith('00')) digits = digits.slice(2);
if (digits.startsWith(countryCode)) digits = digits.slice(countryCode.length);
if (digits.startsWith('0')) digits = digits.slice(1);
return digits;
}
/** Sends the USSD prompt. transactionId is YOUR unique ID for this payment. */
export async function requestPayment({ transactionId, amount, phone, reference }) {
const res = await fetch(`${baseUrl()}/merchant/v1/payments/`, {
method: 'POST',
headers: await headers(),
body: JSON.stringify({
reference: String(reference),
subscriber: { country: env.AIRTEL_COUNTRY, currency: env.AIRTEL_CURRENCY, msisdn: airtelMsisdn(phone) },
transaction: { amount: Number(amount), country: env.AIRTEL_COUNTRY, currency: env.AIRTEL_CURRENCY, id: String(transactionId) },
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data.status?.success === false) {
throw new Error(`Airtel payment request failed: ${data.status?.code ?? res.status} ${data.status?.message ?? ''}`.trim());
}
return data; // only means Airtel received the request
}
/** The source of truth: TS (paid), TF (failed), TIP (in progress), TA (ambiguous). */
export async function getStatus(transactionId) {
const res = await fetch(`${baseUrl()}/standard/v1/payments/${encodeURIComponent(transactionId)}`, {
headers: await headers(),
});
if (!res.ok) throw new Error(`Airtel status failed: ${res.status}`);
const data = await res.json();
const txn = data.data?.transaction ?? {};
return {
status: String(txn.status ?? '').toUpperCase(),
message: txn.message,
airtelMoneyId: txn.airtel_money_id,
code: data.status?.response_code ?? data.status?.code,
};
}
Change countryCode in airtelMsisdn to your market’s calling code, for example '254' for Kenya. Not sure of the format somewhere? Check the number in the African Phone Number Formatter.
The server
The server has three routes: start a payment, let your frontend poll for the status, and receive Airtel’s callback.
The callback handler reads only the transaction ID from the body, then asks Airtel for the real status. Developers report callbacks shaped like {"transaction": {"id": "...", "status_code": "TS"}}, but the shape isn’t consistently documented. Confirming every callback with the status API keeps you safe whatever arrives, including fake callbacks.
// server.js
import express from 'express';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { requestPayment, getStatus } from './airtel.js';
export const app = express();
app.use(express.json());
// Demo store: transactionId -> payment. Use your database in a real app.
const payments = new Map();
app.post('/pay', async (req, res) => {
const { orderId, amount, phone } = req.body ?? {};
const transactionId = randomUUID().replace(/-/g, '').slice(0, 20); // unique per attempt
payments.set(transactionId, { orderId, amount: Number(amount), status: 'pending' }); // save first
try {
await requestPayment({ transactionId, amount, phone, reference: `Order ${orderId}` });
setTimeout(() => confirm(transactionId), 60_000).unref(); // no callback yet? ask Airtel
res.status(202).json({ transactionId, message: 'Check your phone and enter your Airtel Money PIN.' });
} catch (err) {
payments.get(transactionId).status = 'failed';
res.status(502).json({ error: err.message });
}
});
app.get('/pay/:id', (req, res) => {
const payment = payments.get(req.params.id);
if (!payment) return res.sendStatus(404);
res.json({ status: payment.status });
});
app.post('/webhooks/airtel', (req, res) => {
res.sendStatus(200); // acknowledge first, then work
const id = req.body?.transaction?.id;
if (typeof id === 'string' && payments.has(id)) confirm(id);
});
/** Ask Airtel for the real status. Safe to call any number of times. */
async function confirm(id, attempt = 1) {
const payment = payments.get(id);
if (!payment || payment.status !== 'pending') return; // unknown, or already final
const retry = () => attempt < 6 && setTimeout(() => confirm(id, attempt + 1), 20_000 * attempt).unref();
try {
const result = await getStatus(id);
if (result.status === 'TS') Object.assign(payment, { status: 'paid', airtelMoneyId: result.airtelMoneyId });
else if (result.status === 'TF') Object.assign(payment, { status: 'failed', reason: result.message });
else retry(); // TIP (in progress) or TA (ambiguous): check again later
} catch {
retry();
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
app.listen(process.env.PORT ?? 3000, () => console.log('Listening'));
}
Register https://your-domain/webhooks/airtel as the callback URL in the portal, then run node --env-file=.env server.js.
What the code does with each status:
| Status | Meaning | What the code does |
|---|---|---|
TS |
Successful | Marks the payment paid and stores Airtel’s ID |
TF |
Failed | Marks it failed with Airtel’s message |
TIP |
In progress | Checks again later |
TA |
Ambiguous | Checks again later. Never fails it early |
Response codes like DP00800001006 (invalid MSISDN) are explained in the Airtel Money error code reference.
Test it without calling Airtel
This test replaces fetch with a fake Airtel for Airtel URLs, and lets local requests reach the real server. It checks the request format, that a fake “successful” callback is not trusted on its own, and that a successful status marks the payment paid.
// airtel.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
Object.assign(process.env, {
AIRTEL_ENV: 'uat', AIRTEL_CLIENT_ID: 'id', AIRTEL_CLIENT_SECRET: 'secret', AIRTEL_COUNTRY: 'UG', AIRTEL_CURRENCY: 'UGX',
});
const realFetch = globalThis.fetch;
const sent = [];
let airtelStatus = 'TIP';
globalThis.fetch = async (url, options = {}) => {
if (!String(url).startsWith('https://openapiuat.airtel.africa')) return realFetch(url, options);
const reply = (data) => new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
if (String(url).endsWith('/auth/oauth2/token')) return reply({ access_token: 'test-token', expires_in: 3600 });
if (String(url).includes('/merchant/v1/payments/')) {
sent.push({ headers: options.headers, body: JSON.parse(options.body) });
return reply({ status: { success: true, code: '200', result_code: 'ESB000010', message: 'SUCCESS' } });
}
return reply({ data: { transaction: { status: airtelStatus, airtel_money_id: 'MP261028.0741.A00001' } }, status: { success: true } });
};
const { app } = await import('./server.js');
const server = app.listen(0);
const local = `http://localhost:${server.address().port}`;
const post = (path, body) => realFetch(local + path, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const statusOf = async (id) => (await (await realFetch(`${local}/pay/${id}`)).json()).status;
const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
test('payment is confirmed with the status API, not the callback body', async () => {
const res = await post('/pay', { orderId: '1001', amount: 25000, phone: '0752 123 456' });
assert.equal(res.status, 202);
const { transactionId } = await res.json();
const { headers, body } = sent.at(-1);
assert.equal(headers['X-Country'], 'UG');
assert.equal(body.subscriber.msisdn, '752123456');
assert.equal(body.transaction.id, transactionId);
airtelStatus = 'TIP'; // Airtel says still in progress...
await post('/webhooks/airtel', { transaction: { id: transactionId, status_code: 'TS' } }); // ...whatever the body claims
await settle();
assert.equal(await statusOf(transactionId), 'pending');
airtelStatus = 'TS';
await post('/webhooks/airtel', { transaction: { id: transactionId, status_code: 'TS' } });
await settle();
assert.equal(await statusOf(transactionId), 'paid');
});
test('callbacks for unknown transactions are ignored', async () => {
const res = await post('/webhooks/airtel', { transaction: { id: 'not-ours', status_code: 'TS' } });
assert.equal(res.status, 200);
});
test.after(() => server.close());
Run it with node --test.
Going to production
When Airtel approves your application for production:
- Set
AIRTEL_ENV=productionand use your production client ID and secret. - Register the production callback URL (HTTPS) on the production application.
- Check whether your app is enabled for
/merchant/v1/payments/or the newer v2 endpoint, which encrypts the payload. This guide covers v1. - Store the token in a shared cache if you run several processes, and replace the in-memory
Mapwith your database. - Reconcile daily: compare your
paidpayments with the Airtel Money statement, especially any that went throughTA.
Frequently asked questions
Where do I set the Airtel Money callback URL?
In the Airtel developer portal, on your application. Unlike MTN MoMo, Airtel does not take a callback URL in each payment request. Every callback for the app goes to the URL you registered.
Can I trust the status in an Airtel Money callback?
Use it as a signal, not proof. Anyone who finds your callback URL can post to it. When a callback arrives, call GET /standard/v1/payments/{id} and act on that response, as this guide does.
What does Airtel Money status TA mean?
TA means ambiguous: Airtel is not sure yet whether the payment went through. Keep the payment pending and query the status again later. Don't mark it failed or let the customer pay a second time.
Why does Airtel reject my phone number?
subscriber.msisdn must be the number without the country code and without the leading 0, for example 752123456 in Uganda. Sending 256752123456 or 0752123456 is a common cause of invalid MSISDN errors.