Skip to content

Flutterwave · Node.js

Flutterwave Mobile Money Uganda in Node.js: MTN and Airtel

Accept MTN and Airtel mobile money in Uganda with the Flutterwave v3 API in Node.js: charges, verified webhooks, reconciliation and tests.

Published

Skip the boilerplate → Paste a webhook into the Flutterwave Webhook Checker to check its verif-hash and get the verify request.

This guide accepts MTN Mobile Money and Airtel Money in Uganda through Flutterwave’s v3 API, from Node.js. One integration covers both networks. You’ll build:

  • a flutterwave.js client: the Uganda mobile money charge, and verification by transaction ID and by your own reference,
  • a payments.js module that charges once, settles only from verified transactions, and reconciles payments whose webhook never came,
  • an Express server with a webhook that checks Flutterwave’s verif-hash,
  • a test that runs every case without calling Flutterwave.

It uses Node’s built-in fetch (Node 18+). Every code block was run in a fresh project, and the test at the end passes. API facts come from Flutterwave’s public v3 documentation, checked in October 2026. Flutterwave also documents a newer v4 API; this guide uses v3.

How it works

  1. You save a payment with your own tx_ref, then call POST /v3/charges?type=mobile_money_uganda.
  2. Flutterwave answers "Charge initiated" with meta.authorization.mode set to redirect and a URL. You send the customer there; the page waits while they approve on their phone.
  3. Flutterwave sends a charge.completed webhook. You check the verif-hash header, then call verify with the transaction id and settle from that answer.
  4. For any payment still pending, you verify by tx_ref, so a lost webhook never leaves an order stuck.

Flutterwave’s own checks for a verified transaction: the tx_ref is yours, status is successful, the currency matches, and the amount is at least what you expected.

Before you start

  1. In your Flutterwave dashboard, switch to test mode and copy the test secret key.
  2. In the webhook settings, set your webhook URL and a secret hash. Generate a long random one with node -e "console.log(crypto.randomBytes(24).toString('base64url'))". Turn on webhook retries.
  3. Create a .env file (load it with node --env-file=.env) and never commit it:
FLW_SECRET_KEY=your-test-secret-key
FLW_SECRET_HASH=the-same-secret-hash-as-your-dashboard
FLW_REDIRECT_URL=https://yourdomain.com/orders/thanks

Install Express with npm install express, and set "type": "module" in package.json (npm pkg set type=module).

The Flutterwave client

The client throws a FlwError that says whether the failure is ambiguous. A network error, a timeout or a 5xx means the charge may exist at Flutterwave, so the payment stays pending and reconciliation decides. A 4xx is a clear refusal.

// flutterwave.js
const BASE = 'https://api.flutterwave.com/v3';

export class FlwError extends Error {
  constructor(message, { status = 0, ambiguous = false, body = null } = {}) {
    super(message);
    this.status = status;
    this.ambiguous = ambiguous; // true: the charge may exist. Check it; never start a second one.
    this.body = body;
  }
}

async function call(path, init = {}) {
  let res;
  try {
    res = await fetch(`${BASE}${path}`, {
      ...init,
      headers: { Authorization: `Bearer ${process.env.FLW_SECRET_KEY}`, 'Content-Type': 'application/json', ...init.headers },
      signal: AbortSignal.timeout(30_000),
    });
  } catch (err) {
    throw new FlwError(`Flutterwave unreachable: ${err.message}`, { ambiguous: true });
  }
  const body = await res.json().catch(() => null);
  return { res, body };
}

/** Starts a Uganda mobile money charge (MTN or Airtel). Returns where to send the customer. */
export async function chargeUganda({ txRef, amount, phone, network, email, fullname }) {
  const { res, body } = await call('/charges?type=mobile_money_uganda', {
    method: 'POST',
    body: JSON.stringify({
      tx_ref: txRef, // your own reference, saved before this call
      amount, // whole shillings
      currency: 'UGX',
      phone_number: phone,
      network, // 'MTN' or 'AIRTEL'
      email,
      fullname,
      redirect_url: process.env.FLW_REDIRECT_URL,
    }),
  });
  if (res.ok && body?.status === 'success') {
    return { mode: body.meta?.authorization?.mode ?? null, redirect: body.meta?.authorization?.redirect ?? null };
  }
  throw new FlwError(`Charge failed: ${res.status} ${body?.message ?? ''}`.trim(), { status: res.status, ambiguous: res.status >= 500 || !body, body });
}

function verified({ res, body }) {
  if (res.ok && body?.status === 'success' && body.data) return body.data;
  if (res.status === 400 || res.status === 404 || body?.data === null) return null; // no such transaction
  throw new FlwError(`Verify failed: ${res.status}`, { status: res.status, ambiguous: true });
}

/** The source of truth, by Flutterwave's transaction id (data.id in the webhook). */
export async function verifyById(id) {
  return verified(await call(`/transactions/${encodeURIComponent(id)}/verify`));
}

/** The same, by your own tx_ref. Use it for payments you never got a webhook for. */
export async function verifyByRef(txRef) {
  return verified(await call(`/transactions/verify_by_reference?tx_ref=${encodeURIComponent(txRef)}`));
}

Phone number format. Flutterwave’s Uganda page doesn’t say which format phone_number takes, and its examples disagree. Its mobile money transfer docs use the country code without a plus, so this guide sends 256772123456. Test mode accepts payments automatically, so it can’t tell you if the format is wrong: confirm it with one small live payment before launch. The African Phone Number Formatter converts any Ugandan number to this format and tells you whether it’s MTN or Airtel.

Charge once, settle from verified data

A payment goes through these states:

new ──charge──▶ pending ──verified successful──▶ paid
       │            ├──verified failed──▶ failed
       │            ├──short or wrong currency──▶ review
       │            └──no transaction after 1 hour──▶ expired ──late success──▶ paid
       └──4xx (clear refusal)──▶ failed

The rules:

  1. Save before charging. The payment is pending before the network call. If your server crashes mid-request, reconciliation still finds it by tx_ref.
  2. Charge only from new. A double click or a retried request can’t start a second charge.
  3. Never trust the webhook body. Only data.id is read from it. Everything that decides the outcome comes from verifyById, and the payment is looked up by the verified tx_ref.
  4. Money received always wins. A verified success settles a payment even if it had expired or failed. Only paid and review are final.
// payments.js
import { randomUUID } from 'node:crypto';
import { chargeUganda, FlwError, verifyById, verifyByRef } from './flutterwave.js';

const flw = { chargeUganda, verifyById, verifyByRef };
const EXPIRE_AFTER_MS = 60 * 60 * 1000; // no transaction at Flutterwave after an hour: give up

/** Create the payment record. txRef is your reference at Flutterwave, fixed for life. */
export function newPayment({ orderId, amount, phone, network, email, now = Date.now() }) {
  if (!Number.isInteger(amount) || amount <= 0) throw new Error('UGX amounts are whole shillings');
  if (!['MTN', 'AIRTEL'].includes(network)) throw new Error('network must be MTN or AIRTEL');
  return {
    txRef: `order-${orderId}-${randomUUID()}`, amount, currency: 'UGX', phone, network, email,
    status: 'new', flwId: null, reason: null, createdAt: now,
  };
}

/** Starts the charge once. Returns { payment, redirect }: send the customer to redirect. */
export async function startPayment(payment, { save, api = flw }) {
  if (payment.status !== 'new') return { payment, redirect: null }; // already started: never charge twice
  payment = await save({ ...payment, status: 'pending' }); // saved BEFORE the network call
  try {
    const { redirect } = await api.chargeUganda(payment);
    return { payment, redirect };
  } catch (err) {
    if (err instanceof FlwError && !err.ambiguous) {
      return { payment: await save({ ...payment, status: 'failed', reason: err.body?.message ?? `HTTP_${err.status}` }), redirect: null };
    }
    return { payment, redirect: null }; // may exist at Flutterwave: reconcile decides
  }
}

/** Applies a verified transaction (from verifyById or verifyByRef) to its payment. */
export async function settle(payment, tx, { save }) {
  if (!tx || tx.tx_ref !== payment.txRef) return payment; // not this payment
  if (['paid', 'review'].includes(payment.status)) return payment; // already final
  const status = String(tx.status ?? '').toLowerCase();
  if (status === 'successful') {
    const enough = tx.currency === payment.currency && Number(tx.amount) >= payment.amount;
    return save({ ...payment, status: enough ? 'paid' : 'review', flwId: tx.id, reason: enough ? null : `PAID ${tx.amount} ${tx.currency}` });
  }
  if (status === 'failed') return save({ ...payment, status: 'failed', flwId: tx.id, reason: tx.processor_response ?? 'failed' });
  return payment; // still pending at Flutterwave
}

/**
 * Handles a webhook whose verif-hash you already checked.
 * Only data.id is taken from the body; everything else comes from verifyById.
 */
export async function handleWebhook(body, { find, save, api = flw }) {
  const id = body?.data?.id;
  if (!id) return null;
  const tx = await api.verifyById(id);
  if (!tx?.tx_ref) return null;
  const payment = await find(tx.tx_ref); // found by the VERIFIED reference
  return payment ? settle(payment, tx, { save }) : null;
}

/** For a payment still pending: checks Flutterwave, and expires it if no charge ever appeared. */
export async function reconcile(payment, { save, api = flw, now = Date.now() }) {
  if (!['pending', 'expired'].includes(payment.status)) return payment;
  let tx;
  try {
    tx = await api.verifyByRef(payment.txRef);
  } catch {
    return payment; // Flutterwave unreachable: try again on the next run
  }
  if (tx) return settle(payment, tx, { save });
  if (payment.status === 'pending' && now - payment.createdAt > EXPIRE_AFTER_MS) {
    return save({ ...payment, status: 'expired', reason: 'NO_TRANSACTION' });
  }
  return payment;
}

Your save function should update the row by txRef and refuse to change a payment that is paid or review, for example with WHERE status NOT IN ('paid', 'review'). Then a repeated webhook, a late lookup or a slow reconcile can never undo a payment.

review means the customer paid less than the order, or in another currency. Flutterwave suggests giving value for what was paid and refunding the rest; a person should decide.

The server

// server.js (Express)
import express from 'express';
import { timingSafeEqual } from 'node:crypto';
import { handleWebhook, newPayment, reconcile, startPayment } from './payments.js';
import { find, save } from './db.js'; // your database: find(txRef) and save(payment)

const secretHash = process.env.FLW_SECRET_HASH ?? '';
if (secretHash.length < 32) throw new Error('Set FLW_SECRET_HASH to a random string of 32+ characters (and the same value in the dashboard)');

const app = express();
app.use(express.json());

function hashOk(given) {
  const a = Buffer.from(String(given ?? ''));
  const b = Buffer.from(secretHash);
  return a.length === b.length && timingSafeEqual(a, b);
}

// Checkout: create the payment, start the charge, send the customer to Flutterwave's page.
app.post('/pay', async (req, res) => {
  const { orderId, amount, phone, network, email } = req.body; // in real code, load the amount from the order
  let created;
  try {
    created = newPayment({ orderId, amount: Number(amount), phone, network, email });
  } catch (err) {
    return res.status(422).json({ error: err.message });
  }
  const { payment, redirect } = await startPayment(created, { save });
  if (redirect) return res.json({ txRef: payment.txRef, redirect });
  res.status(payment.status === 'failed' ? 400 : 202).json({ txRef: payment.txRef, status: payment.status });
});

// Webhook: check the secret hash, then let verifyById decide.
app.post('/webhooks/flw', async (req, res) => {
  if (!hashOk(req.get('verif-hash'))) return res.sendStatus(401);
  try {
    await handleWebhook(req.body, { find, save });
    res.sendStatus(200);
  } catch (err) {
    console.error('Flutterwave webhook not processed', req.body?.data?.id, err);
    res.sendStatus(500); // Flutterwave retries if retries are on in your dashboard
  }
});

// Your order page polls this until the status is final.
app.get('/payments/:txRef', async (req, res) => {
  let payment = await find(req.params.txRef);
  if (!payment) return res.sendStatus(404);
  if (payment.status === 'pending') payment = await reconcile(payment, { save });
  res.json({ status: payment.status });
});

app.listen(3000);

The webhook answers 200 only after the payment is saved. If saving fails it answers 500, and Flutterwave retries three times, 30 minutes apart, when retries are on in your dashboard. Flutterwave treats any response slower than 60 seconds, or any status other than 200 (including redirects), as a failure.

Got a webhook you want to inspect? Paste it into the Flutterwave Webhook Checker: it checks the verif-hash, explains the event and gives you the verify request.

Run reconcile from a job every few minutes for payments that are pending, and hourly for a day for ones that are expired. Rate-limit the /payments/:txRef route: each call can make a request to Flutterwave.

Test it without calling Flutterwave

This test replaces the client with a fake Flutterwave. It checks that a payment is saved before the charge and charged once, that a forged webhook body changes nothing, that one payment’s transaction can’t settle another, that short or wrong-currency payments go to review, that a timeout stays pending and reconcile settles it, and that a late success beats an expiry.

// payments.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { FlwError } from './flutterwave.js';
import { handleWebhook, newPayment, reconcile, settle, startPayment } from './payments.js';

function makeDb() {
  const rows = new Map();
  return {
    rows,
    find: async (ref) => rows.get(ref) ?? null,
    save: async (p) => {
      const old = rows.get(p.txRef);
      if (old && ['paid', 'review'].includes(old.status)) return old; // final results never change
      rows.set(p.txRef, p);
      return p;
    },
  };
}

function fakeFlutterwave() {
  const txs = new Map(); // id -> transaction, like Flutterwave's side
  let nextId = 1000;
  return {
    txs,
    charges: [],
    async chargeUganda(p) {
      this.charges.push(p.txRef);
      const id = nextId++;
      txs.set(id, { id, tx_ref: p.txRef, amount: p.amount, currency: 'UGX', status: 'pending' });
      return { mode: 'redirect', redirect: `https://checkout.example/${id}` };
    },
    async verifyById(id) {
      return txs.get(Number(id)) ?? null;
    },
    async verifyByRef(ref) {
      return [...txs.values()].find((t) => t.tx_ref === ref) ?? null;
    },
    complete(ref, changes) {
      const tx = [...txs.values()].find((t) => t.tx_ref === ref);
      Object.assign(tx, changes);
      return tx;
    },
  };
}

const order = (extra = {}) => newPayment({ orderId: 42, amount: 15000, phone: '256772123456', network: 'MTN', email: 'buyer@example.com', ...extra });

test('a payment is saved before the charge, charged once, and paid from the verified transaction', async () => {
  const db = makeDb();
  const api = fakeFlutterwave();
  const real = api.chargeUganda.bind(api);
  api.chargeUganda = async (p) => {
    assert.equal(db.rows.get(p.txRef)?.status, 'pending'); // saved BEFORE the charge
    return real(p);
  };
  const { payment, redirect } = await startPayment(order(), { ...db, api });
  assert.match(redirect, /^https:\/\/checkout/);
  await startPayment(payment, { ...db, api }); // a double click charges nothing
  assert.equal(api.charges.length, 1);

  const tx = api.complete(payment.txRef, { status: 'successful' });
  const settled = await handleWebhook({ event: 'charge.completed', data: { id: tx.id } }, { ...db, api });
  assert.deepEqual([settled.status, settled.flwId], ['paid', tx.id]);
});

test('the webhook body is never trusted: only the verified transaction counts', async () => {
  const db = makeDb();
  const api = fakeFlutterwave();
  const { payment } = await startPayment(order(), { ...db, api });
  const tx = [...api.txs.values()][0];
  // A forged body says "successful", but Flutterwave still says pending.
  await handleWebhook({ event: 'charge.completed', data: { id: tx.id, tx_ref: payment.txRef, status: 'successful', amount: 15000 } }, { ...db, api });
  assert.equal(db.rows.get(payment.txRef).status, 'pending');
  // A body pointing at a transaction that doesn't exist changes nothing.
  assert.equal(await handleWebhook({ data: { id: 999999, tx_ref: payment.txRef } }, { ...db, api }), null);
  assert.equal(await handleWebhook({ hello: 'world' }, { ...db, api }), null);
});

test('a real transaction for a different payment cannot settle this one', async () => {
  const db = makeDb();
  const api = fakeFlutterwave();
  const { payment: cheap } = await startPayment(order({ amount: 500 }), { ...db, api });
  const { payment: dear } = await startPayment(order({ amount: 90000 }), { ...db, api });
  const cheapTx = api.complete(cheap.txRef, { status: 'successful' });
  assert.equal((await settle(dear, cheapTx, db)).status, 'pending'); // tx_ref doesn't match
  // A body that names the dear payment's tx_ref with the cheap transaction's id settles only the cheap one.
  assert.equal((await handleWebhook({ data: { id: cheapTx.id, tx_ref: dear.txRef } }, { ...db, api })).txRef, cheap.txRef);
  assert.equal(db.rows.get(cheap.txRef).status, 'paid');
  assert.equal(db.rows.get(dear.txRef).status, 'pending');
});

test('too little or the wrong currency goes to review, not paid', async () => {
  const db = makeDb();
  const api = fakeFlutterwave();
  const { payment } = await startPayment(order(), { ...db, api });
  assert.equal((await settle(payment, { id: 1, tx_ref: payment.txRef, status: 'successful', amount: 1500, currency: 'UGX' }, db)).status, 'review');
  const { payment: other } = await startPayment(order(), { ...db, api });
  assert.equal((await settle(other, { id: 2, tx_ref: other.txRef, status: 'successful', amount: 15000, currency: 'KES' }, db)).status, 'review');
});

test('a failed charge fails, and a final result is never undone', async () => {
  const db = makeDb();
  const api = fakeFlutterwave();
  const { payment } = await startPayment(order(), { ...db, api });
  let tx = api.complete(payment.txRef, { status: 'failed', processor_response: 'Insufficient funds' });
  let p = await handleWebhook({ data: { id: tx.id } }, { ...db, api });
  assert.deepEqual([p.status, p.reason], ['failed', 'Insufficient funds']);

  const { payment: second } = await startPayment(order(), { ...db, api });
  tx = api.complete(second.txRef, { status: 'successful' });
  await handleWebhook({ data: { id: tx.id } }, { ...db, api });
  tx.status = 'failed'; // even if a later lookup disagreed...
  await handleWebhook({ data: { id: tx.id } }, { ...db, api });
  assert.equal(db.rows.get(second.txRef).status, 'paid'); // ...paid stays paid
});

test('a clear refusal fails at once; a timeout stays pending and reconcile settles it', async () => {
  const db = makeDb();
  const refused = { ...fakeFlutterwave(), async chargeUganda() { throw new FlwError('Charge failed: 400 Invalid phone number', { status: 400, body: { message: 'Invalid phone number' } }); } };
  const r = await startPayment(order(), { ...db, api: refused });
  assert.deepEqual([r.payment.status, r.payment.reason], ['failed', 'Invalid phone number']);

  const api = fakeFlutterwave();
  const real = api.chargeUganda.bind(api);
  api.chargeUganda = async (p) => {
    await real(p); // Flutterwave created it...
    throw new FlwError('Flutterwave unreachable: timeout', { ambiguous: true }); // ...but we never heard back
  };
  const { payment, redirect } = await startPayment(order(), { ...db, api });
  assert.deepEqual([payment.status, redirect], ['pending', null]);
  api.complete(payment.txRef, { status: 'successful' });
  assert.equal((await reconcile(payment, { ...db, api })).status, 'paid');
});

test('a payment with no transaction expires, but a late success still counts', async () => {
  const db = makeDb();
  const api = { ...fakeFlutterwave(), async chargeUganda() { throw new FlwError('Flutterwave unreachable', { ambiguous: true }); } };
  const { payment } = await startPayment(order({ now: 0 }), { ...db, api });
  assert.equal((await reconcile(payment, { ...db, api, now: 10 * 60 * 1000 })).status, 'pending'); // too early to give up
  const expired = await reconcile(payment, { ...db, api, now: 2 * 60 * 60 * 1000 });
  assert.equal(expired.status, 'expired');
  const late = { id: 7, tx_ref: payment.txRef, amount: 15000, currency: 'UGX', status: 'successful' };
  assert.equal((await reconcile(expired, { ...db, api: { ...api, verifyByRef: async () => late } })).status, 'paid'); // money received always wins
});

test('reconcile leaves a payment alone when Flutterwave is unreachable', async () => {
  const db = makeDb();
  const api = fakeFlutterwave();
  const { payment } = await startPayment(order({ now: 0 }), { ...db, api });
  const down = { ...api, async verifyByRef() { throw new FlwError('Flutterwave unreachable', { ambiguous: true }); } };
  assert.equal((await reconcile(payment, { ...db, api: down, now: 5 * 60 * 60 * 1000 })).status, 'pending');
});

test('only whole shillings and MTN or Airtel are accepted', () => {
  assert.throws(() => order({ amount: 99.5 }), /whole shillings/);
  assert.throws(() => order({ network: 'mtn' }), /MTN or AIRTEL/);
  assert.notEqual(order().txRef, order().txRef); // a new reference every time
});

Run it with node --test.

Test in test mode

With the tests passing, start the server with your test keys and a tunnel, and point the dashboard webhook URL at https://your-tunnel.example.com/webhooks/flw. Create a payment with POST /pay and open the redirect URL. Flutterwave says Ugandan mobile money payments in test mode are authorised automatically after a few seconds, so the webhook should follow shortly. Then stop the server before the webhook arrives, restart it, and watch GET /payments/:txRef settle the payment through reconcile.

Common errors and fixes

Symptom Likely cause Fix
Webhooks get 401 The verif-hash header doesn’t match FLW_SECRET_HASH Use exactly the same secret hash in the dashboard and in .env
401 from the API Test key in live mode, or the reverse Use the secret key for the mode you’re in
"No transaction was found for this id" Verifying an ID from the wrong mode, or a made-up ID Expected for forged webhooks; check test vs live keys otherwise
Payment stays pending No webhook arrived Check the webhook URL in the dashboard; reconcile settles it either way
review Paid amount or currency differs from the order Look at the transaction in the dashboard and decide: give value or refund

Going to production

  • Complete Flutterwave’s business verification, then switch to your live secret key and set a new live secret hash.
  • Load the amount from your order on the server. Never take it from the browser, as the /pay example does for brevity.
  • Store payments with txRef as a unique key, and make save refuse to change a paid or review payment.
  • Run reconcile on a schedule. Flutterwave itself recommends not relying only on webhooks.
  • Send one small live payment on MTN and one on Airtel before launch, to confirm the phone format and your webhook.

Using Laravel or Django? See Flutterwave in Laravel and Flutterwave in Django.

Prefer to go direct? See MTN MoMo Collections in Node.js and Airtel Money collections in Node.js.

Frequently asked questions

How do I verify a Flutterwave webhook?

In the v3 API, set a secret hash in your dashboard. Flutterwave sends that same value in the verif-hash header, so compare the two with a constant-time comparison. Then call GET /v3/transactions/{id}/verify with data.id from the webhook, and use that response, not the webhook body.

Is a "Charge initiated" response a successful payment?

No. It means Flutterwave started the charge and gave you a redirect URL for the customer. The payment is complete only when the verify endpoint returns data.status successful with the right tx_ref, currency and amount.

What if I never receive the webhook?

Verify by your own reference with GET /v3/transactions/verify_by_reference?tx_ref=... . Flutterwave recommends polling pending transactions periodically rather than relying only on webhooks.

How do I test Uganda mobile money without real money?

Use your Flutterwave test mode keys. Flutterwave says that in test mode, Ugandan mobile money payments are automatically authorized after a few seconds.

Do I need Flutterwave if I can use MTN MoMo and Airtel Money directly?

No, but it is one integration and one settlement for both networks, plus cards. Going direct means two separate API approvals but no aggregator fees. See the MTN MoMo and Airtel Money guides on this site for the direct route.

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.