Skip to content

Airtel Money · Node.js

Airtel Money Collections API in Node.js: USSD Push to Callback

Accept Airtel Money in Node.js: token caching, USSD push payments, safe callbacks confirmed by the status API, TA/TIP handling and offline tests.

Published

Skip the boilerplate → Generate every Airtel Money request in this guide with the Airtel Money Request Builder.

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.js client with token caching,
  • an Express server that starts payments and receives callbacks,
  • status checks that confirm every callback, and resolve TIP and TA payments,
  • 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

  1. Create an application in the Airtel developer portal (developers.airtel.africa) for your country, with the Collection product.
  2. 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.
  3. Register your callback URL on the application. It must be public HTTPS. For local development, use a tunnel such as cloudflared or ngrok.
  4. 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=production and 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 Map with your database.
  • Reconcile daily: compare your paid payments with the Airtel Money statement, especially any that went through TA.

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.

Need this built for you?

I integrate MTN MoMo, M-Pesa and Airtel Money into Laravel / PHP, Python / Django, Node.js / Next.js apps: payments, callbacks, payouts and go-live, with tests.