Skip to content

Pesapal · Next.js

Pesapal API 3.0 in Next.js App Router: Orders, IPN and Status

Accept M-Pesa, MTN and card payments through Pesapal API 3.0 in a Next.js App Router app: orders, the IPN route, settling from GetTransactionStatus, tests.

Published

Skip the boilerplate → Generate every Pesapal API 3.0 request in this guide, in cURL, Node.js, PHP or Python, with the Pesapal Request Builder.

This guide accepts payments through Pesapal API 3.0 in a Next.js App Router app. Pesapal hosts the payment page, where the customer picks how to pay (Pesapal’s status answers name methods such as M-Pesa, MTN and cards). You’ll build:

  • lib/pesapal.ts: a cached token, IPN registration, orders and transaction status,
  • lib/pesapal-payments.ts: save each order before sending it, settle only from GetTransactionStatus, and never settle a payment twice,
  • route handlers for the order, the customer’s return (callback) and the IPN,
  • a reconcile route, protected by a secret, and a script to register your IPN URL,
  • tests that run every case without calling Pesapal.

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. API facts come from Pesapal’s API 3.0 documentation at developer.pesapal.com, checked in November 2026. The same design in other stacks: Node.js, Laravel and Django.

How it works

  1. Get a token: POST /api/Auth/RequestToken with your consumer key and secret. It lasts at most 5 minutes.
  2. Register your IPN URL once: POST /api/URLSetup/RegisterIPN. Keep the ipn_id it returns.
  3. Submit an order: POST /api/Transactions/SubmitOrderRequest with your own id, the amount, currency, callback_url, notification_id (the ipn_id) and a billing address. Pesapal answers with an order_tracking_id and a redirect_url.
  4. Send the customer to redirect_url. They pay there.
  5. Pesapal redirects them to your callback_url and calls your IPN URL, both with OrderTrackingId, OrderMerchantReference and OrderNotificationType. Neither carries the payment status.
  6. Ask for the status: GET /api/Transactions/GetTransactionStatus?orderTrackingId=...: status_code 0 INVALID, 1 COMPLETED, 2 FAILED, 3 REVERSED.

Base URLs: sandbox https://cybqa.pesapal.com/pesapalv3, live https://pay.pesapal.com/v3.

Before you start

Pesapal publishes demo consumer keys for the sandbox (linked from the authentication page of its developer docs). Live keys are emailed to you when your Pesapal business account is opened. Your IPN URL must be public; for local testing, use a tunnel.

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

PESAPAL_BASE_URL=https://cybqa.pesapal.com/pesapalv3
PESAPAL_CONSUMER_KEY=your-consumer-key
PESAPAL_CONSUMER_SECRET=your-consumer-secret
PESAPAL_IPN_ID=
PUBLIC_URL=https://your-app.example.com
CRON_SECRET=a-long-random-string

The Pesapal client

// lib/pesapal.ts
const base = () => (process.env.PESAPAL_BASE_URL ?? 'https://cybqa.pesapal.com/pesapalv3').replace(/\/+$/, '');

type Json = Record<string, unknown>;

export class PesapalError extends Error {
  /** ambiguous: the request may have worked. Look it up before doing anything else. */
  constructor(message: string, readonly ambiguous = false, readonly code = '') {
    super(message);
  }
}

async function call(path: string, init: { method?: string; body?: Json; token?: string } = {}): Promise<Json> {
  let res: Response;
  try {
    res = await fetch(`${base()}${path}`, {
      method: init.method ?? 'GET',
      headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(init.token ? { Authorization: `Bearer ${init.token}` } : {}) },
      body: init.body ? JSON.stringify(init.body) : undefined,
      cache: 'no-store',
      signal: AbortSignal.timeout(30_000),
    });
  } catch (err) {
    throw new PesapalError(`Pesapal unreachable: ${(err as Error).message}`, true);
  }
  const data = (await res.json().catch(() => null)) as Json | null;
  if (res.status >= 500 || !data || typeof data !== 'object') throw new PesapalError(`Pesapal HTTP ${res.status}`, true);
  // Successful answers can carry an error object whose fields are all null: only a code or message is an error.
  const e = (data.error ?? null) as Json | null;
  const error = e && typeof e === 'object' && (e.code || e.message) ? e : null;
  if (!res.ok || error) throw new PesapalError(`Pesapal error: ${String(error?.code ?? res.status)} ${String(error?.message ?? data.message ?? '')}`.trim(), false, String(error?.code ?? ''));
  return data;
}

let cached: { token: string; expires: number } | null = null;

/** A bearer token, cached until 30 seconds before its expiryDate (UTC). Pesapal tokens last at most 5 minutes. */
async function token(): Promise<string> {
  const now = Date.now();
  if (cached && cached.expires - 30_000 > now) return cached.token;
  const data = await call('/api/Auth/RequestToken', {
    method: 'POST',
    body: { consumer_key: process.env.PESAPAL_CONSUMER_KEY, consumer_secret: process.env.PESAPAL_CONSUMER_SECRET },
  });
  if (typeof data.token !== 'string') throw new PesapalError('No token in the response');
  const raw = String(data.expiryDate ?? '');
  const expires = Date.parse(/[zZ]|[+-]\d\d:\d\d$/.test(raw) ? raw : `${raw}Z`);
  cached = { token: data.token, expires: Number.isFinite(expires) ? Math.min(expires, now + 5 * 60_000) : now + 4 * 60_000 };
  return cached.token;
}

/** Registers your IPN URL. Do it once per environment: the ipn_id is the notification_id of every order. */
export async function registerIpn(url: string, method: 'GET' | 'POST' = 'POST'): Promise<string> {
  const data = await call('/api/URLSetup/RegisterIPN', { method: 'POST', token: await token(), body: { url, ipn_notification_type: method } });
  return String(data.ipn_id ?? '');
}

export interface Order {
  id: string;
  currency: string;
  amount: number;
  description: string;
  callback_url: string;
  notification_id: string;
  billing_address: { email_address?: string; phone_number?: string };
}

/** Creates the order. Nothing is paid yet. */
export async function submitOrder(order: Order): Promise<{ order_tracking_id: string; redirect_url: string }> {
  const data = await call('/api/Transactions/SubmitOrderRequest', { method: 'POST', token: await token(), body: order as unknown as Json });
  if (typeof data.order_tracking_id !== 'string' || typeof data.redirect_url !== 'string') throw new PesapalError('Order response without order_tracking_id or redirect_url', true);
  return { order_tracking_id: data.order_tracking_id, redirect_url: data.redirect_url };
}

export interface TxStatus {
  status_code: number;
  merchant_reference: string;
  amount: number;
  currency: string;
  confirmation_code?: string;
  description?: string;
}

/** The source of truth for an order. A failed lookup proves nothing about the payment, so it's always ambiguous. */
export async function getStatus(orderTrackingId: string): Promise<TxStatus> {
  try {
    return (await call(`/api/Transactions/GetTransactionStatus?orderTrackingId=${encodeURIComponent(orderTrackingId)}`, { token: await token() })) as unknown as TxStatus;
  } catch (err) {
    if (err instanceof PesapalError) throw new PesapalError(err.message, true, err.code);
    throw err;
  }
}

Register the IPN once per environment (the sandbox and live sites are separate) with a small script, and put the ipn_id in .env.local as PESAPAL_IPN_ID:

// scripts/pesapal-register-ipn.ts  (run: npx tsx --env-file=.env.local scripts/pesapal-register-ipn.ts)
import { registerIpn } from '@/lib/pesapal';

registerIpn(`${process.env.PUBLIC_URL}/api/webhooks/pesapal`, 'POST').then(
  (id) => console.log(`PESAPAL_IPN_ID=${id}`),
  (err) => {
    console.error(err.message);
    process.exitCode = 1;
  },
);

Order once, settle from the status check

pending ──status 1, same amount & currency──▶ paid ──status 3──▶ reversed
   ├──status 1, other amount or currency──▶ review
   ├──status 2, or the order was refused──▶ failed
   └──status 0 or no answer──▶ pending, expired after an hour

Your database goes here. This file keeps payments in a Map so the guide runs on its own. When you replace save, make it refuse to change a payment that is already final, in one statement, for example UPDATE payments SET ... WHERE reference = $1 AND status IN ('pending', 'paid').

// lib/pesapal-payments.ts
import { randomUUID } from 'node:crypto';
import { getStatus, PesapalError, submitOrder, type TxStatus } from './pesapal';

export type Status = 'pending' | 'paid' | 'failed' | 'review' | 'reversed' | 'expired';
export interface Payment {
  reference: string; // Pesapal's order id: letters, numbers, - _ . : only, at most 50, unique per order request
  orderId: string;
  amount: number; // in cents, so amounts compare exactly
  currency: string;
  email: string;
  status: Status;
  trackingId?: string;
  note?: string;
  createdAt: number;
}

const FINAL: Status[] = ['failed', 'review', 'reversed', 'expired'];
const EXPIRE_AFTER_MS = 60 * 60_000;

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

export async function findPayment(reference: string): Promise<Payment | null> {
  const p = rows.get(reference);
  return p ? { ...p } : null;
}
export async function pendingPayments(olderThanMs: number): Promise<Payment[]> {
  const cutoff = Date.now() - olderThanMs;
  return [...rows.values()].filter((p) => p.status === 'pending' && p.createdAt < cutoff).slice(0, 100);
}
/** Writes the change unless the stored payment is already final. True if it was written. */
async function save(next: Payment, from: Status[]): Promise<boolean> {
  const current = rows.get(next.reference);
  if (current && !from.includes(current.status)) return false;
  rows.set(next.reference, { ...next });
  return true;
}
// ────────────────────────────────────────────────────────────────────────────

/** "1500.50" -> 150050. Throws RangeError for anything else. */
export function toCents(amount: unknown): number {
  const m = /^(\d{1,12})(?:\.(\d{1,2}))?$/.exec(String(amount ?? '').trim());
  if (!m) throw new RangeError('Enter an amount like 1500 or 1500.50');
  const cents = Number(m[1]) * 100 + Number((m[2] ?? '').padEnd(2, '0'));
  if (cents <= 0) throw new RangeError('Amount must be more than zero');
  return cents;
}

/** Saves the payment, then creates the Pesapal order. Returns the payment and the page to send the customer to. */
export async function startPayment(input: { orderId: string; amount: unknown; currency: string; email: string }): Promise<{ payment: Payment; redirectUrl?: string }> {
  const currency = String(input.currency ?? '').toUpperCase();
  if (!/^[A-Z]{3}$/.test(currency)) throw new RangeError('Use an ISO currency code, e.g. KES, UGX or TZS');
  if (!/^[^@\s]+@[^@\s]+$/.test(String(input.email ?? ''))) throw new RangeError('Pesapal needs the customer\'s email address or phone number');
  const payment: Payment = {
    reference: `pay-${randomUUID()}`, orderId: String(input.orderId).slice(0, 64), amount: toCents(input.amount), currency,
    email: input.email, status: 'pending', createdAt: Date.now(),
  };
  await save(payment, []); // saved BEFORE the network call
  try {
    const order = await submitOrder({
      id: payment.reference,
      currency,
      amount: payment.amount / 100,
      description: `Order ${payment.orderId}`.slice(0, 100),
      callback_url: `${process.env.PUBLIC_URL}/api/payments/pesapal/callback`,
      notification_id: process.env.PESAPAL_IPN_ID ?? '',
      billing_address: { email_address: payment.email },
    });
    await save({ ...payment, trackingId: order.order_tracking_id }, ['pending']);
    return { payment: (await findPayment(payment.reference))!, redirectUrl: order.redirect_url };
  } catch (err) {
    if (!(err instanceof PesapalError)) throw err;
    // Refused: failed. Ambiguous: the order may exist, so it stays pending; its IPN can still settle it.
    if (!err.ambiguous) await save({ ...payment, status: 'failed', note: err.code || 'REFUSED' }, ['pending']);
    return { payment: (await findPayment(payment.reference))! };
  }
}

/** Applies a GetTransactionStatus answer. Returns the new status only on the call that changed it. */
export async function settle(payment: Payment, tx: TxStatus): Promise<Status | null> {
  if (tx.merchant_reference !== payment.reference || FINAL.includes(payment.status)) return null;
  const code = Number(tx.status_code);
  if (code === 3) return payment.status === 'paid' && (await save({ ...payment, status: 'reversed', note: 'REVERSED' }, ['paid'])) ? 'reversed' : null;
  if (payment.status === 'paid') return null; // only a reversal changes a paid payment
  if (code === 1) {
    const same = Math.round(Number(tx.amount) * 100) === payment.amount && String(tx.currency).toUpperCase() === payment.currency;
    const to: Status = same ? 'paid' : 'review'; // paid a different amount: a person must look
    return (await save({ ...payment, status: to, note: same ? tx.confirmation_code : `PAID ${tx.amount} ${tx.currency}` }, ['pending'])) ? to : null;
  }
  if (code === 2) return (await save({ ...payment, status: 'failed', note: tx.description || 'FAILED' }, ['pending'])) ? 'failed' : null;
  return null; // 0 (INVALID) or anything else: not paid yet
}

/**
 * The callback and the IPN. Neither carries the status: the only thing taken from them is which order
 * to look up, and the tracking id must be the one Pesapal gave us for that reference.
 */
export async function handleNotification(trackingId: unknown, reference: unknown): Promise<{ payment: Payment | null; changed: Status | null }> {
  if (typeof trackingId !== 'string' || typeof reference !== 'string' || !trackingId || !reference) return { payment: null, changed: null };
  const payment = await findPayment(reference);
  if (!payment || (payment.trackingId && payment.trackingId !== trackingId)) return { payment: null, changed: null };
  const tx = await getStatus(trackingId); // throws if Pesapal is unreachable: try again later
  if (tx.merchant_reference !== payment.reference) return { payment, changed: null };
  if (!payment.trackingId) await save({ ...payment, trackingId }, ['pending', 'paid']);
  const changed = await settle((await findPayment(reference))!, tx);
  return { payment: await findPayment(reference), changed };
}

/** For payments still pending: asks Pesapal, and expires payments that never completed. */
export async function reconcile(payment: Payment): Promise<Status | null> {
  if (payment.trackingId) {
    try {
      const changed = await settle(payment, await getStatus(payment.trackingId));
      if (changed) return changed;
    } catch (err) {
      if (!(err instanceof PesapalError)) throw err;
      return null; // Pesapal unreachable: try again on the next run
    }
  }
  if (Date.now() - payment.createdAt > EXPIRE_AFTER_MS) return (await save({ ...payment, status: 'expired', note: 'NO_PAYMENT' }, ['pending'])) ? 'expired' : null;
  return null;
}

The rules:

  1. Save before you send. The payment is pending before SubmitOrderRequest, with a reference that is unique for every order request, as Pesapal requires.
  2. Never settle from the callback or IPN. They only say which order to look up, and the tracking id must match the one Pesapal gave you for that reference.
  3. Paid means status 1 with the same amount and currency. Anything else from a “completed” order goes to review for a person.
  4. Final means final. failed, review, reversed and expired never change, and paid changes only to reversed.

The route handlers

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

export async function POST(request: Request): Promise<Response> {
  const body = await request.json().catch(() => null);
  // In a real shop, take the amount and currency from your order record, never from the browser.
  try {
    const { payment, redirectUrl } = await startPayment({ orderId: String(body?.orderId ?? ''), amount: body?.amount, currency: body?.currency, email: body?.email });
    if (!redirectUrl) return Response.json({ error: 'Could not start the payment. Please try again.' }, { status: 502 });
    return Response.json({ reference: payment.reference, redirectUrl });
  } catch (err) {
    if (err instanceof RangeError) return Response.json({ error: err.message }, { status: 422 });
    throw err;
  }
}

The customer comes back to the callback route. It checks the status, then sends them to your own order page:

// app/api/payments/pesapal/callback/route.ts
import { handleNotification } from '@/lib/pesapal-payments';

export async function GET(request: Request): Promise<Response> {
  const q = new URL(request.url).searchParams;
  let status = 'pending';
  try {
    const { payment } = await handleNotification(q.get('OrderTrackingId'), q.get('OrderMerchantReference'));
    if (payment) status = payment.status;
  } catch {
    // Pesapal unreachable: show "confirming"; the IPN or the reconcile route will settle it.
  }
  const reference = encodeURIComponent(q.get('OrderMerchantReference') ?? '');
  return Response.redirect(new URL(`/checkout/result?reference=${reference}&status=${status}`, request.url), 303);
}

Show the result page from your own database, not from the status in its URL: that’s only a hint, and anyone can edit a URL.

// app/api/webhooks/pesapal/route.ts
import { handleNotification } from '@/lib/pesapal-payments';

async function ipn(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const body = request.method === 'POST' ? await request.json().catch(() => ({})) : Object.fromEntries(url.searchParams);
  const { OrderTrackingId, OrderMerchantReference, OrderNotificationType } = (body ?? {}) as Record<string, unknown>;
  const echo = { orderNotificationType: OrderNotificationType, orderTrackingId: OrderTrackingId, orderMerchantReference: OrderMerchantReference };
  try {
    const { changed } = await handleNotification(OrderTrackingId, OrderMerchantReference);
    if (changed === 'paid') {
      // Fulfil the order here, or queue a job that does. This runs once per payment.
      console.info('Pesapal payment confirmed', OrderMerchantReference);
    }
  } catch (err) {
    console.error('Pesapal IPN not processed', OrderMerchantReference, (err as Error).message);
    return Response.json({ ...echo, status: 500 }, { status: 500 });
  }
  return Response.json({ ...echo, status: 200 });
}

export const GET = ipn;
export const POST = ipn;

The IPN answer. Pesapal’s API 3.0 pages say what it sends to your IPN URL but not what your server must answer. This route answers HTTP 200 with the three values echoed back and status: 200, and 500 if it couldn’t ask Pesapal. Confirm in the sandbox that Pesapal accepts it, and keep the reconcile route running either way.

Reconcile pending payments

// app/api/cron/reconcile-pesapal/route.ts
import { timingSafeEqual } from 'node:crypto';
import { pendingPayments, reconcile } from '@/lib/pesapal-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> = {};
  for (const p of await pendingPayments(60_000)) {
    const result = (await reconcile(p)) ?? 'pending';
    counts[result] = (counts[result] ?? 0) + 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-pesapal

Test it without calling Pesapal

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

npm install --save-dev tsx
npx tsx --test tests/pesapal.test.ts
// tests/pesapal.test.ts
import { test, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { POST as order } from '@/app/api/payments/pesapal/route';
import { GET as callback } from '@/app/api/payments/pesapal/callback/route';
import { GET as ipnGet, POST as ipnPost } from '@/app/api/webhooks/pesapal/route';
import { GET as reconcileRoute } from '@/app/api/cron/reconcile-pesapal/route';
import { findPayment, startPayment, toCents } from '@/lib/pesapal-payments';

Object.assign(process.env, { PESAPAL_BASE_URL: 'https://pesapal.test/v3', PESAPAL_IPN_ID: 'ipn-guid', PUBLIC_URL: 'https://shop.test', CRON_SECRET: 'cron-secret-for-tests' });

let tx: Record<string, unknown> = {};
let submit: 'ok' | 'refused' | 'down' = 'ok';
let lookupDown = false;
let orders: Record<string, unknown>[] = [];
let lookups = 0;
let tokens = 0;

globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
  const u = new URL(String(url));
  const path = u.pathname.replace('/v3', '');
  if (path === '/api/Auth/RequestToken') {
    tokens += 1;
    return Response.json({ token: 'test-token', expiryDate: new Date(Date.now() + 5 * 60_000).toISOString().slice(0, 19), error: null, status: '200' });
  }
  if (path === '/api/Transactions/SubmitOrderRequest') {
    if (submit === 'down') throw new TypeError('fetch failed');
    const body = JSON.parse(String(init?.body));
    orders.push(body);
    if (submit === 'refused') return Response.json({ error: { type: 'api_error', code: 'invalid_currency', message: 'Currency not supported' }, status: '400' });
    return Response.json({ order_tracking_id: `trk-${orders.length}`, merchant_reference: body.id, redirect_url: 'https://pay.test/iframe', error: null, status: '200' });
  }
  if (path === '/api/Transactions/GetTransactionStatus') {
    lookups += 1;
    if (lookupDown) return new Response('down', { status: 503 });
    return Response.json({ payment_method: 'MpesaKE', error: { error_type: null, code: null, message: null }, status: '200', ...tx });
  }
  throw new Error(`unexpected ${path}`);
}) as typeof fetch;

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

async function pay(amount: unknown = '1500', currency = 'KES') {
  const res = await order(new Request('http://localhost/api/payments/pesapal', post({ orderId: '1001', amount, currency, email: 'a@example.com' })));
  const body = await res.json();
  return { res, body, payment: body.reference ? await findPayment(body.reference) : null };
}
const ipn = (trackingId: string, reference: string) =>
  ipnPost(new Request('http://localhost/api/webhooks/pesapal', post({ OrderTrackingId: trackingId, OrderMerchantReference: reference, OrderNotificationType: 'IPNCHANGE' })));
const completed = (reference: string, amount = 1500, currency = 'KES') => ({ status_code: 1, merchant_reference: reference, amount, currency, confirmation_code: 'QK1234ABCD' });

beforeEach(() => {
  tx = {};
  submit = 'ok';
  lookupDown = false;
  orders = [];
  lookups = 0;
});

test('the order carries the reference, IPN id and callback URL; the token is cached', async () => {
  const { res, body, payment } = await pay();
  await pay();
  assert.equal(res.status, 200);
  assert.equal(body.redirectUrl, 'https://pay.test/iframe');
  assert.deepEqual([orders[0].id, orders[0].amount, orders[0].currency, orders[0].notification_id, orders[0].callback_url],
    [payment!.reference, 1500, 'KES', 'ipn-guid', 'https://shop.test/api/payments/pesapal/callback']);
  assert.equal(payment!.trackingId, 'trk-1');
  assert.equal(tokens, 1);
});

test('the IPN is settled from GetTransactionStatus, once, with the all-null error object ignored', async () => {
  const { payment } = await pay();
  tx = { status_code: 0, merchant_reference: payment!.reference, amount: 1500, currency: 'KES' };
  const res = await ipn('trk-1', payment!.reference);
  assert.deepEqual(await res.json(), { orderNotificationType: 'IPNCHANGE', orderTrackingId: 'trk-1', orderMerchantReference: payment!.reference, status: 200 });
  assert.equal((await findPayment(payment!.reference))?.status, 'pending');
  tx = completed(payment!.reference);
  const logged: unknown[] = [];
  const info = console.info;
  console.info = (...args: unknown[]) => void logged.push(args);
  try {
    await Promise.all([1, 2, 3].map(() => ipn('trk-1', payment!.reference)));
  } finally {
    console.info = info;
  }
  assert.equal(logged.length, 1);
  assert.deepEqual([(await findPayment(payment!.reference))?.status, (await findPayment(payment!.reference))?.note], ['paid', 'QK1234ABCD']);
});

test('a forged IPN with another tracking id, or someone else\'s order, changes nothing', async () => {
  const { payment } = await pay();
  tx = completed(payment!.reference);
  await ipn('trk-someone-else', payment!.reference);
  tx = completed('pay-not-this-one');
  await ipn('trk-1', payment!.reference);
  assert.equal((await findPayment(payment!.reference))?.status, 'pending');
  await ipn('trk-1', 'pay-unknown');
  assert.equal(lookups, 1); // only the matching tracking id was looked up
});

test('a different amount goes to review; paid changes only to reversed; final means final', async () => {
  const a = (await pay()).payment!;
  tx = completed(a.reference, 150);
  await ipn('trk-1', a.reference);
  assert.equal((await findPayment(a.reference))?.status, 'review');
  tx = completed(a.reference);
  await ipn('trk-1', a.reference);
  assert.equal((await findPayment(a.reference))?.status, 'review');
  const b = (await pay()).payment!;
  tx = completed(b.reference);
  await ipn('trk-2', b.reference);
  tx = { status_code: 2, merchant_reference: b.reference, amount: 1500, currency: 'KES' };
  await ipn('trk-2', b.reference);
  assert.equal((await findPayment(b.reference))?.status, 'paid');
  tx = { status_code: 3, merchant_reference: b.reference, amount: 1500, currency: 'KES' };
  await ipnGet(new Request(`http://localhost/api/webhooks/pesapal?OrderTrackingId=trk-2&OrderMerchantReference=${b.reference}&OrderNotificationType=IPNCHANGE`));
  assert.equal((await findPayment(b.reference))?.status, 'reversed');
});

test('a refused order fails; an unanswered one stays pending; bad input never reaches Pesapal', async () => {
  submit = 'refused';
  const refused = await pay();
  assert.equal(refused.res.status, 502);
  submit = 'down';
  const lost = await pay();
  assert.equal(lost.res.status, 502);
  submit = 'ok';
  assert.equal((await pay('15.555')).res.status, 422);
  assert.equal((await pay('1500', 'shillings')).res.status, 422);
  assert.equal(orders.length, 1);
  assert.deepEqual([refused.payment, lost.payment], [null, null]); // the route doesn't hand out references it can't pay
});

test('the callback checks before redirecting; an unreachable Pesapal answers the IPN with 500', async () => {
  const { payment } = await pay();
  const back = () => callback(new Request(`https://shop.test/api/payments/pesapal/callback?OrderTrackingId=trk-1&OrderMerchantReference=${payment!.reference}&OrderNotificationType=CALLBACKURL`));
  tx = { status_code: 0, merchant_reference: payment!.reference, amount: 1500, currency: 'KES' };
  assert.equal(new URL((await back()).headers.get('location')!).searchParams.get('status'), 'pending');
  tx = completed(payment!.reference);
  const res = await callback(new Request(`https://shop.test/api/payments/pesapal/callback?OrderTrackingId=trk-1&OrderMerchantReference=${payment!.reference}&OrderNotificationType=CALLBACKURL`));
  assert.equal(res.status, 303);
  assert.equal(new URL(res.headers.get('location')!).searchParams.get('status'), 'paid');
  const other = (await pay()).payment!;
  lookupDown = true;
  assert.equal((await ipn('trk-2', other.reference)).status, 500);
  assert.equal((await findPayment(other.reference))?.status, 'pending');
});

test('reconcile needs the secret, settles pending payments and expires old ones', async () => {
  const a = (await pay()).payment!;
  submit = 'down';
  const b = (await startPayment({ orderId: '1002', amount: '1500', currency: 'KES', email: 'a@example.com' })).payment; // no tracking id: can only expire
  submit = 'ok';
  tx = completed(a.reference);
  const auth = { headers: { authorization: 'Bearer cron-secret-for-tests' } };
  assert.equal((await reconcileRoute(new Request('http://localhost'))).status, 401);
  const old = Date.now;
  try {
    Date.now = () => old() + 2 * 60_000;
    await reconcileRoute(new Request('http://localhost', auth));
    assert.deepEqual([(await findPayment(a.reference))?.status, (await findPayment(b.reference))?.status], ['paid', 'pending']);
    Date.now = () => old() + 2 * 60 * 60_000;
    await reconcileRoute(new Request('http://localhost', auth));
    assert.equal((await findPayment(b.reference))?.status, 'expired');
  } finally {
    Date.now = old;
  }
});

test('amounts in cents', () => {
  assert.deepEqual(['1500', '1500.5', '1500.50', '0.01'].map(toCents), [150000, 150050, 150050, 1]);
  assert.throws(() => toCents('1,500'), RangeError);
});

Common errors and fixes

Every Pesapal status and error is explained in the Pesapal error reference.

Symptom Likely cause Fix
Every status check looks like an error The all-null error object was treated as an error Only an error with a code or message is one, as in call()
Calls start failing after a few minutes The token expired: it lasts at most 5 minutes Cache it until shortly before expiryDate (UTC), as token() does
IPN never arrives Wrong notification_id, or the IPN was registered on the other site Register once per environment; use that environment’s ipn_id
Duplicate order id error The id was reused Every order request needs a new reference
IPN route redirects to a login page Auth middleware runs on it Exclude /api/webhooks and the callback route from the middleware matcher
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

  • Use your live consumer key and secret and PESAPAL_BASE_URL=https://pay.pesapal.com/v3.
  • Register the IPN on the live site and set that ipn_id.
  • Replace the Map in lib/pesapal-payments.ts with your database, keeping the conditional update.
  • Take the amount and currency from your order, never from the request body.
  • Schedule the reconcile route, and check every review payment by hand.

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

Frequently asked questions

Does the Pesapal IPN tell my Next.js app whether the customer paid?

No. Pesapal says the callback URL and the IPN calls do not carry the payment status. Both only give you the OrderTrackingId and your OrderMerchantReference. Call GetTransactionStatus from the route handler and settle from that.

Should the Pesapal IPN route handle GET or POST?

Whichever you registered with RegisterIPN (ipn_notification_type GET or POST). The route in this guide exports both, so it keeps working if you register the other one.

How long does a Pesapal API 3.0 token last?

At most 5 minutes, with an expiryDate in UTC. Cache it per server instance and request a new one shortly before it expires.

Can I redirect to Pesapal from a server action?

Yes. Create the order on the server, then redirect() to the redirect_url Pesapal returns. This guide uses a route handler that returns the URL, so the browser can also load it in an iframe.

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.