Skip to content

Airtel Money · Next.js

Airtel Money Collections API in Next.js App Router: USSD Push

Accept Airtel Money in a Next.js App Router app: USSD push from a route handler, callbacks confirmed by the status API, safe TIP and TA handling, and tests.

Published

Skip the boilerplate → Generate every Airtel Money request in this guide, in Node.js or cURL, with the Airtel Money Request Builder.

This guide adds Airtel Money payments to a Next.js App Router app with Airtel’s Open API. The customer gets a USSD prompt on their phone and approves the payment with their PIN. You’ll build:

  • lib/airtel.ts: token caching, the payment request and the status check,
  • lib/airtel-payments.ts: start a payment (saved before Airtel is called) and settle it once from Airtel’s own answer,
  • route handlers to start a payment, poll it, and receive Airtel’s callback,
  • handling for TIP (in progress) and TA (ambiguous) that never fails a payment early,
  • a reconcile route for callbacks that never arrive, protected by a secret,
  • tests that run the whole flow without calling Airtel.

Every code block was run in a fresh Next.js 16 app (create-next-app with TypeScript and the App Router): the tests pass and next build succeeds. The same design in other stacks: Node.js, Laravel and Django.

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. UAT (sandbox) and production have separate credentials.
  3. Register your callback URL on the application, for example https://your-app.com/api/webhooks/airtel. It must be public HTTPS; for local development, use a tunnel.

Put the settings in .env.local (Next.js keeps it out of git by default):

AIRTEL_ENV=uat
AIRTEL_CLIENT_ID=your-client-id
AIRTEL_CLIENT_SECRET=your-client-secret
AIRTEL_COUNTRY=UG
AIRTEL_CURRENCY=UGX
AIRTEL_COUNTRY_CODE=256
CRON_SECRET=a-long-random-string

The Airtel client

Every call carries the X-Country and X-Currency headers for your market. Network failures and 5xx answers throw AirtelUnclearError: Airtel may have received the request.

// lib/airtel.ts
const env = process.env;
const base = () => (env.AIRTEL_ENV === 'production' ? 'https://openapi.airtel.africa' : 'https://openapiuat.airtel.africa');

/** Network failures and 5xx answers: Airtel may have received the request. */
export class AirtelUnclearError extends Error {}
/** Airtel refused the request: no prompt was sent. */
export class AirtelRejectedError extends Error {}

type Json = Record<string, unknown>;
const obj = (v: unknown): Json => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : {});

let token: { value: string; expiresAt: number } | null = null;

async function call(path: string, init: RequestInit): Promise<{ status: number; data: Json }> {
  let res: Response;
  try {
    res = await fetch(`${base()}${path}`, { ...init, cache: 'no-store', signal: AbortSignal.timeout(30_000) });
  } catch (err) {
    throw new AirtelUnclearError(`Airtel unreachable: ${(err as Error).message}`);
  }
  return { status: res.status, data: obj(await res.json().catch(() => ({}))) };
}

async function getToken(): Promise<string> {
  if (token && Date.now() < token.expiresAt - 60_000) return token.value;
  const { status, data } = await call('/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 (status >= 300 || typeof data.access_token !== 'string') throw new AirtelUnclearError(`Airtel token failed: HTTP ${status}`);
  token = { value: data.access_token, expiresAt: Date.now() + (Number(data.expires_in) || 180) * 1000 };
  return token.value;
}

async function headers(): Promise<Record<string, string>> {
  return {
    Authorization: `Bearer ${await getToken()}`,
    Accept: '*/*',
    'Content-Type': 'application/json',
    'X-Country': env.AIRTEL_COUNTRY ?? 'UG',
    'X-Currency': env.AIRTEL_CURRENCY ?? 'UGX',
  };
}

/** Airtel wants the number without the country code or leading 0: "0752 123 456" -> "752123456". */
export function msisdn(raw: unknown, countryCode = env.AIRTEL_COUNTRY_CODE ?? '256'): string {
  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);
  if (digits.length < 8 || digits.length > 10) throw new RangeError(`Not a valid mobile number: ${String(raw)}`);
  return digits;
}

/** Sends the USSD prompt. Success only means Airtel received the request. */
export async function requestPayment(transactionId: string, amount: number, number: string, reference: string): Promise<void> {
  const country = env.AIRTEL_COUNTRY ?? 'UG';
  const currency = env.AIRTEL_CURRENCY ?? 'UGX';
  const { status, data } = await call('/merchant/v1/payments/', {
    method: 'POST',
    headers: await headers(),
    body: JSON.stringify({
      reference: reference.slice(0, 64),
      subscriber: { country, currency, msisdn: number },
      transaction: { amount, country, currency, id: transactionId },
    }),
  });
  const s = obj(data.status);
  if (status < 300 && s.success === true) return;
  if (status >= 500 || Object.keys(data).length === 0) throw new AirtelUnclearError(`Airtel HTTP ${status}`);
  throw new AirtelRejectedError(`${String(s.response_code ?? s.code ?? status)} ${String(s.message ?? '')}`.trim());
}

export interface AirtelStatus {
  status: string; // TS | TF | TIP | TA | "" (no answer)
  airtelMoneyId: string;
  code: string;
}

/** The source of truth for a payment. */
export async function paymentStatus(transactionId: string): Promise<AirtelStatus> {
  const { status, data } = await call(`/standard/v1/payments/${encodeURIComponent(transactionId)}`, { headers: await headers() });
  if (status >= 500) throw new AirtelUnclearError(`Airtel HTTP ${status}`);
  const txn = obj(obj(data.data).transaction);
  const s = obj(data.status);
  return {
    status: String(txn.status ?? '').toUpperCase(),
    airtelMoneyId: String(txn.airtel_money_id ?? ''),
    code: String(s.response_code ?? txn.message ?? ''),
  };
}

Change AIRTEL_COUNTRY_CODE to your market’s calling code, for example 254 for Kenya. Not sure of the format? Check the number in the African Phone Number Formatter.

Start a payment and settle it once

Your database goes here. This file keeps payments in a Map so the guide runs on its own. When you replace the store functions, keep the rule in settle: change a row only if it is still pending, in one statement, for example UPDATE payments SET status = $2 WHERE transaction_id = $1 AND status = 'pending'.

// lib/airtel-payments.ts
import { randomBytes } from 'node:crypto';
import { AirtelRejectedError, AirtelUnclearError, msisdn, paymentStatus, requestPayment } from './airtel';

export interface Payment {
  transactionId: string; // yours, sent as transaction.id
  orderId: string;
  amount: number; // whole units: UGX, KES, TZS...
  msisdn: string;
  status: 'pending' | 'paid' | 'failed';
  airtelMoneyId?: string;
  reason?: string;
  createdAt: number;
}

// ── Replace with your database ──────────────────────────────────────────────
const rows = new Map<string, Payment>();

async function insert(p: Payment): Promise<void> {
  rows.set(p.transactionId, { ...p });
}
export async function findPayment(transactionId: string): Promise<Payment | null> {
  const p = rows.get(transactionId);
  return p ? { ...p } : null;
}
export async function pendingBetween(minAgeMs: number, maxAgeMs: number): Promise<Payment[]> {
  const now = Date.now();
  return [...rows.values()].filter((p) => p.status === 'pending' && now - p.createdAt > minAgeMs && now - p.createdAt < maxAgeMs).slice(0, 200);
}
/** Changes a payment only while it is still pending. True if this call changed it. */
async function settle(transactionId: string, fields: Partial<Payment>): Promise<boolean> {
  const p = rows.get(transactionId);
  if (!p || p.status !== 'pending') return false;
  rows.set(transactionId, { ...p, ...fields });
  return true;
}
// ────────────────────────────────────────────────────────────────────────────

/** Saves the payment, then sends the prompt. Throws RangeError for bad input. */
export async function startPayment(orderId: string, amount: number, phone: unknown): Promise<Payment> {
  if (!Number.isInteger(amount) || amount <= 0) throw new RangeError('Amount must be a whole number greater than zero');
  const payment: Payment = {
    transactionId: randomBytes(10).toString('hex'), // 20 characters, never reused
    orderId: orderId.slice(0, 64), amount, msisdn: msisdn(phone), status: 'pending', createdAt: Date.now(),
  };
  await insert(payment); // saved BEFORE Airtel is called
  try {
    await requestPayment(payment.transactionId, amount, payment.msisdn, `Order ${payment.orderId}`);
  } catch (err) {
    if (err instanceof AirtelRejectedError) await settle(payment.transactionId, { status: 'failed', reason: err.message.slice(0, 190) });
    else if (err instanceof AirtelUnclearError) console.warn('Airtel may have received', payment.transactionId, err.message); // stays pending
    else throw err;
  }
  return (await findPayment(payment.transactionId))!;
}

/** Asks Airtel for the real status and settles the payment once. Returns the new status only on the call that changed it. */
export async function confirmPayment(transactionId: string): Promise<Payment['status'] | null> {
  const payment = await findPayment(transactionId);
  if (!payment || payment.status !== 'pending') return null; // unknown, or already settled
  const r = await paymentStatus(transactionId);
  if (r.status === 'TS') return (await settle(transactionId, { status: 'paid', airtelMoneyId: r.airtelMoneyId })) ? 'paid' : null;
  if (r.status === 'TF') return (await settle(transactionId, { status: 'failed', reason: (r.code || 'TF').slice(0, 190) })) ? 'failed' : null;
  return null; // TIP, TA or no answer: leave it pending, the reconcile route asks again
}
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 code or message
TIP In progress Leaves it pending; it is asked again later
TA Ambiguous Leaves it pending. Never fails it early

Response codes like DP00800001006 are explained in the Airtel Money error code reference.

The route handlers

Start a payment from your checkout page with fetch('/api/payments/airtel', …), then poll /api/payments/airtel/<id> every few seconds until the status is no longer pending.

// app/api/payments/airtel/route.ts
import { startPayment } from '@/lib/airtel-payments';

export async function POST(request: Request): Promise<Response> {
  const body = await request.json().catch(() => null);
  // In a real shop, take the amount from your order record, never from the browser.
  try {
    const payment = await startPayment(String(body?.orderId ?? ''), Number(body?.amount), body?.phone);
    if (payment.status === 'failed') {
      return Response.json({ error: 'Airtel Money could not send the payment prompt. Please try again.' }, { status: 502 });
    }
    return Response.json({ transactionId: payment.transactionId, message: 'Check your phone and enter your Airtel Money PIN.' }, { status: 202 });
  } catch (err) {
    if (err instanceof RangeError) return Response.json({ error: err.message }, { status: 422 });
    throw err;
  }
}
// app/api/payments/airtel/[id]/route.ts
import { findPayment } from '@/lib/airtel-payments';

export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }): Promise<Response> {
  const payment = await findPayment((await params).id);
  if (!payment) return Response.json({ error: 'Not found' }, { status: 404 });
  return Response.json({ status: payment.status });
}

Developers report callbacks shaped like {"transaction": {"id": "...", "status_code": "TS"}}, but the shape isn’t consistently documented. The callback route only reads transaction.id and asks Airtel, so a different shape, or a fake callback, can’t mark anything paid.

// app/api/webhooks/airtel/route.ts
import { AirtelUnclearError } from '@/lib/airtel';
import { confirmPayment } from '@/lib/airtel-payments';

export async function POST(request: Request): Promise<Response> {
  const body = await request.json().catch(() => null);
  const id = body?.transaction?.id;
  if (typeof id === 'string') {
    try {
      const changed = await confirmPayment(id); // asks Airtel; the body's status_code is ignored
      if (changed === 'paid') {
        // Fulfil the order here, or queue a job that does. This runs once per payment.
        console.info('Airtel payment confirmed', id);
      }
    } catch (err) {
      if (!(err instanceof AirtelUnclearError)) throw err;
      console.warn('Could not confirm; the reconcile route will retry', id);
    }
  }
  return Response.json({ ok: true }); // always acknowledge
}

Catch callbacks that never arrive

Callbacks can be late or lost, and TA payments need another look. This route re-checks every payment pending for more than two minutes, for up to a day.

// app/api/cron/reconcile-airtel/route.ts
import { timingSafeEqual } from 'node:crypto';
import { AirtelUnclearError } from '@/lib/airtel';
import { confirmPayment, pendingBetween } from '@/lib/airtel-payments';

function authorised(header: string | null): boolean {
  const secret = process.env.CRON_SECRET ?? '';
  const given = Buffer.from(header ?? '');
  const expected = Buffer.from(`Bearer ${secret}`);
  return secret.length >= 16 && given.length === expected.length && timingSafeEqual(given, expected);
}

export async function GET(request: Request): Promise<Response> {
  if (!authorised(request.headers.get('authorization'))) return new Response('Unauthorized', { status: 401 });
  const counts: Record<string, number> = { paid: 0, failed: 0, pending: 0 };
  for (const p of await pendingBetween(2 * 60_000, 24 * 60 * 60_000)) {
    try {
      counts[(await confirmPayment(p.transactionId)) ?? 'pending'] += 1;
    } catch (err) {
      if (!(err instanceof AirtelUnclearError)) throw err;
      counts.pending += 1;
    }
  }
  return Response.json(counts);
}

Call it every few minutes with Authorization: Bearer <CRON_SECRET>. On Vercel, add the path to crons in vercel.json; Vercel sends that header when CRON_SECRET is set. Anywhere else:

curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-app.com/api/cron/reconcile-airtel

Test it without calling Airtel

The test calls the exported handlers directly and replaces fetch with a fake Airtel. Install a TypeScript test runner that understands the @/ path alias:

npm install --save-dev tsx
npx tsx --test tests/airtel.test.ts
// tests/airtel.test.ts
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { POST as start } from '@/app/api/payments/airtel/route';
import { GET as status } from '@/app/api/payments/airtel/[id]/route';
import { POST as callback } from '@/app/api/webhooks/airtel/route';
import { GET as reconcile } from '@/app/api/cron/reconcile-airtel/route';
import { confirmPayment, findPayment } from '@/lib/airtel-payments';
import { msisdn } from '@/lib/airtel';

Object.assign(process.env, { AIRTEL_COUNTRY: 'UG', AIRTEL_CURRENCY: 'UGX', AIRTEL_COUNTRY_CODE: '256', AIRTEL_CLIENT_ID: 'id', AIRTEL_CLIENT_SECRET: 'secret', CRON_SECRET: 'cron-secret-for-tests' });

let airtel = 'TIP';
let mode: 'ok' | 'rejected' | 'down' = 'ok';
let statusDown = false;
let sent: { headers: Record<string, string>; body: Record<string, unknown> }[] = [];
let lookups = 0;
let tokens = 0;

globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
  const path = new URL(String(url)).pathname;
  if (path === '/auth/oauth2/token') {
    tokens += 1;
    return Response.json({ access_token: 'test-token', expires_in: 3600 });
  }
  if (path === '/merchant/v1/payments/') {
    if (mode === 'down') throw new TypeError('fetch failed');
    sent.push({ headers: init?.headers as Record<string, string>, body: JSON.parse(String(init?.body)) });
    if (mode === 'rejected') return Response.json({ status: { success: false, response_code: 'DP00800001006', message: 'Invalid MSISDN' } }, { status: 400 });
    return Response.json({ status: { success: true, code: '200' } });
  }
  if (path.startsWith('/standard/v1/payments/')) {
    lookups += 1;
    if (statusDown) return new Response('Service unavailable', { status: 503 });
    return Response.json({ data: { transaction: { status: airtel, airtel_money_id: 'MP261106.0741.A00001' } }, status: { success: true } });
  }
  throw new Error(`unexpected ${path}`);
}) as typeof fetch;

const json = (body: unknown) => ({ method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' } });

async function pay(phone = '0752 123 456', amount: unknown = 25000) {
  const res = await start(new Request('http://localhost/api/payments/airtel', json({ orderId: '1001', amount, phone })));
  const body = await res.json();
  return { res, payment: body.transactionId ? await findPayment(body.transactionId) : null };
}
const notify = (body: unknown) => callback(new Request('http://localhost/api/webhooks/airtel', json(body)));

beforeEach(() => {
  airtel = 'TIP';
  mode = 'ok';
  statusDown = false;
  sent = [];
  lookups = 0;
});

test('the request has the headers and body Airtel expects; the token is cached', async () => {
  const { res, payment } = await pay();
  await pay();
  assert.equal(res.status, 202);
  const { headers, body } = sent[0];
  assert.deepEqual([headers['X-Country'], headers['X-Currency'], headers.Authorization], ['UG', 'UGX', 'Bearer test-token']);
  assert.deepEqual(body.subscriber, { country: 'UG', currency: 'UGX', msisdn: '752123456' });
  assert.deepEqual(body.transaction, { amount: 25000, country: 'UG', currency: 'UGX', id: payment!.transactionId });
  assert.equal(tokens, 1);
});

test('the callback is confirmed with the status API, not trusted', async () => {
  const { payment } = await pay();
  const id = payment!.transactionId;
  assert.equal((await notify({ transaction: { id, status_code: 'TS' } })).status, 200);
  assert.equal((await findPayment(id))?.status, 'pending'); // the body says TS, Airtel says TIP
  airtel = 'TS';
  const logged: unknown[] = [];
  const info = console.info;
  console.info = (...args: unknown[]) => void logged.push(args);
  try {
    await Promise.all([1, 2, 3].map(() => notify({ transaction: { id } })));
  } finally {
    console.info = info;
  }
  assert.equal(logged.length, 1);
  assert.equal((await findPayment(id))?.airtelMoneyId, 'MP261106.0741.A00001');
  const res = await status(new Request('http://localhost'), { params: Promise.resolve({ id }) });
  assert.deepEqual(await res.json(), { status: 'paid' });
});

test('TF fails; final means final', async () => {
  const a = (await pay('+256752123456')).payment!;
  airtel = 'TF';
  await notify({ transaction: { id: a.transactionId } });
  airtel = 'TS';
  await confirmPayment(a.transactionId);
  assert.equal((await findPayment(a.transactionId))?.status, 'failed');
  const b = (await pay()).payment!;
  await confirmPayment(b.transactionId);
  airtel = 'TF';
  await notify({ transaction: { id: b.transactionId } });
  assert.equal((await findPayment(b.transactionId))?.status, 'paid');
});

test('TA and an unreachable Airtel leave it pending', async () => {
  const { payment } = await pay();
  airtel = 'TA';
  assert.equal(await confirmPayment(payment!.transactionId), null);
  statusDown = true;
  assert.equal((await notify({ transaction: { id: payment!.transactionId } })).status, 200);
  assert.equal((await findPayment(payment!.transactionId))?.status, 'pending');
});

test('a timeout stays pending; a rejection fails; bad input never reaches Airtel', async () => {
  mode = 'down';
  const timedOut = await pay();
  assert.deepEqual([timedOut.res.status, timedOut.payment?.status], [202, 'pending']);
  mode = 'rejected';
  const rejected = await pay();
  assert.equal(rejected.res.status, 502);
  mode = 'ok';
  assert.equal((await pay('12345')).res.status, 422);
  assert.equal((await pay('0752123456', 12.5)).res.status, 422);
  assert.equal(sent.length, 1);
});

test('unknown callbacks and junk are acknowledged and ignored', async () => {
  assert.equal((await notify({ transaction: { id: 'not-ours', status_code: 'TS' } })).status, 200);
  assert.equal((await notify({ hello: 'world' })).status, 200);
  assert.equal((await callback(new Request('http://localhost', { method: 'POST', body: 'not json' }))).status, 200);
  assert.equal(lookups, 0);
});

test('reconcile needs the secret and only checks payments between two minutes and a day old', async () => {
  const { payment } = await pay();
  airtel = 'TS';
  const auth = { headers: { authorization: 'Bearer cron-secret-for-tests' } };
  assert.equal((await reconcile(new Request('http://localhost'))).status, 401);
  await reconcile(new Request('http://localhost', auth));
  assert.equal((await findPayment(payment!.transactionId))?.status, 'pending'); // too recent
  const old = Date.now;
  try {
    Date.now = () => old() + 5 * 60_000;
    await reconcile(new Request('http://localhost', auth));
  } finally {
    Date.now = old;
  }
  assert.equal((await findPayment(payment!.transactionId))?.status, 'paid');
});

test('msisdn formats', () => {
  assert.deepEqual(['0752 123 456', '+256752123456', '00256752123456', '752123456'].map((n) => msisdn(n, '256')), Array(4).fill('752123456'));
  assert.equal(msisdn('254733123456', '254'), '733123456');
});

Common errors and fixes

Problem Likely cause Fix
Invalid MSISDN (developers report DP00800001006) Number sent with the country code or a leading 0 Send 752123456, not 256752123456 or 0752123456
401 on payment or status Expired token, or UAT keys against production The cached token refreshes a minute early. Check AIRTEL_ENV matches your keys
Request accepted but no prompt Wrong AIRTEL_COUNTRY or AIRTEL_CURRENCY for the number Use your market’s values, for example UG and UGX
Callback never arrives URL not registered, not public HTTPS, or auth middleware redirects it Register it in the portal; exclude /api/webhooks from the middleware matcher. The reconcile route settles payments meanwhile
Status stays TA Airtel hasn’t resolved the payment yet Keep it pending. Check your Airtel statement if it lasts a day
Route segment config "runtime" is not compatible with nextConfig.cacheComponents export const runtime in a Next.js 16 app Delete it: route handlers use Node.js by default

Going to production

  • Set AIRTEL_ENV=production with 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.
  • Replace the Map in lib/airtel-payments.ts with your database, keeping the conditional update.
  • Schedule the reconcile route, and reconcile daily against your Airtel Money statement, especially payments that went through TA.

Want this already built, combined with MTN MoMo, M-Pesa, Flutterwave, Paystack, Pesapal and payouts? The Next.js starter kit has it ready.

Frequently asked questions

Where do I set the Airtel Money callback URL for a Next.js app?

In the Airtel developer portal, on your application, for example https://your-app.com/api/webhooks/airtel. Airtel does not take a callback URL in each payment request, so every callback for the app goes there.

Can I trust the status in an Airtel Money callback?

Use it as a signal, not proof. Callbacks are not signed, so anyone who finds the URL can post to it. The route handler in this guide reads only transaction.id and asks GET /standard/v1/payments/{id} before changing anything.

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 again later. Don't mark it failed or let the customer pay a second time.

Can I call the Airtel API from a Next.js client component?

No. The client secret must stay on the server. Start the payment in a route handler (or a server action) and let the browser poll your own route for the status.

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.