Skip to content

Paystack · Node.js

Paystack Mobile Money in Node.js: Ghana and Kenya (MTN, M-Pesa)

Accept MTN, Telecel, AT and M-Pesa mobile money with the Paystack Charge API in Node.js: subunits, signed webhooks, verify and reconcile, with tests.

Published

Skip the boilerplate → Paste a webhook into the Paystack Webhook Checker to check x-paystack-signature and see what your handler must do.

This guide accepts mobile money in Ghana and Kenya through Paystack’s Charge API, from Node.js: MTN, Telecel and AT Money in Ghana, and M-Pesa and Airtel Money in Kenya. You’ll build:

  • a paystack.js client: the mobile money charge, verification, and the webhook signature check,
  • a payments.js module that charges once, settles only from verified transactions, and reconciles payments whose webhook never came,
  • an Express server whose webhook checks x-paystack-signature over the raw body,
  • a test that runs every case without calling Paystack.

It uses Node’s built-in fetch and crypto (Node 18+). Every code block was run in a fresh project, and the test at the end passes. API facts come from Paystack’s public documentation, checked in November 2026.

How it works

  1. You save a payment with your own reference, then call POST https://api.paystack.co/charge with a mobile_money object.
  2. Paystack answers with data.status set to pay_offline and a display_text. The customer approves on their phone; Paystack says they have 180 seconds.
  3. Paystack sends a charge.success webhook. You check its signature, then call GET /transaction/verify/:reference and settle from that answer.
  4. Webhooks fire only for successful payments, so failed and abandoned ones are found by verifying pending payments.
Country Currency Provider codes
Ghana GHS (subunit: pesewa) mtn (MTN), vod (Telecel), atl (AT Money)
Kenya KES (subunit: cent) mpesa (M-Pesa), atl (Airtel Money)

Paystack also supports Côte d’Ivoire (mtn, orange, wave in XOF); this guide covers Ghana and Kenya. Mobile money is only available to Paystack businesses registered in those countries.

Before you start

  1. In your Paystack dashboard, copy the test secret key from Settings → API Keys & Webhooks.
  2. Set your webhook URL there, for example https://your-tunnel.example.com/webhooks/paystack. Paystack signs webhooks with the same secret key, so there’s no separate webhook secret.
  3. Create a .env file (load it with node --env-file=.env) and never commit it:
PAYSTACK_SECRET_KEY=sk_test_your_test_secret_key

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

The Paystack client

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

// paystack.js
import { createHmac, timingSafeEqual } from 'node:crypto';

const BASE = 'https://api.paystack.co';

export class PaystackError 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.PAYSTACK_SECRET_KEY}`, 'Content-Type': 'application/json', ...init.headers },
      signal: AbortSignal.timeout(30_000),
    });
  } catch (err) {
    throw new PaystackError(`Paystack unreachable: ${err.message}`, { ambiguous: true });
  }
  const body = await res.json().catch(() => null);
  if (res.status >= 500 || !body) throw new PaystackError(`Paystack HTTP ${res.status}`, { status: res.status, ambiguous: true, body });
  return { res, body };
}

/** Starts a mobile money charge. Returns Paystack's data: status, display_text, reference. */
export async function chargeMobileMoney({ reference, email, amount, currency, phone, provider }) {
  const { res, body } = await call('/charge', {
    method: 'POST',
    body: JSON.stringify({
      reference, // your own reference, saved before this call
      email,
      amount, // in the currency's subunit: pesewas for GHS, cents for KES
      currency,
      mobile_money: { phone, provider },
    }),
  });
  if (!res.ok || body.status !== true) {
    throw new PaystackError(`Charge failed: ${body.message ?? res.status}`, { status: res.status, body });
  }
  return body.data; // data.status is usually "pay_offline": the customer approves on their phone
}

/** The source of truth for a reference, or null if Paystack has no such transaction (yet). */
export async function verify(reference) {
  const { res, body } = await call(`/transaction/verify/${encodeURIComponent(reference)}`);
  return res.ok && body.status === true && body.data ? body.data : null;
}

/** x-paystack-signature is an HMAC SHA512 of the raw request body, keyed with your secret key. */
export function signatureOk(rawBody, signature, secret = process.env.PAYSTACK_SECRET_KEY ?? '') {
  if (!secret || typeof signature !== 'string') return false;
  const expected = Buffer.from(createHmac('sha512', secret).update(rawBody).digest('hex'));
  const given = Buffer.from(signature);
  return given.length === expected.length && timingSafeEqual(given, expected);
}

Two details trip people up:

  • Amounts are in the subunit. Paystack’s API docs say every amount “must be sent in the subunit of that currency”: GHS 25.50 is 2550 pesewas, KES 100 is 10000 cents.
  • Check data.status, not status. The top-level status: true only says the API call worked. Whether the customer paid is in data.status.

Charge once, settle from verified data

new ──charge──▶ pending ──verify: success, exact amount──▶ paid
       │            ├──verify: failed / abandoned / reversed──▶ failed
       │            ├──success, but another amount or currency──▶ review
       │            └──no transaction after 1 hour──▶ expired ──late success──▶ paid
       └──4xx or data.status "failed"──▶ failed
// payments.js
import { randomUUID } from 'node:crypto';
import { chargeMobileMoney, PaystackError, verify } from './paystack.js';

const paystack = { chargeMobileMoney, verify };
const PROVIDERS = { GHS: ['mtn', 'atl', 'vod'], KES: ['mpesa', 'atl'] };
const EXPIRE_AFTER_MS = 60 * 60 * 1000; // no transaction after an hour: nothing was charged

/** "12.50" -> 1250. Paystack amounts are in the subunit: pesewas (GHS) or cents (KES). */
export function toSubunits(amount) {
  const m = /^(\d{1,9})(?:\.(\d{1,2}))?$/.exec(String(amount).trim());
  if (!m) throw new Error('Enter an amount like 25 or 25.50');
  return Number(m[1]) * 100 + Number((m[2] ?? '').padEnd(2, '0'));
}

/** Ghana "+233 55 123 4987" or Kenya "+254 710 000000" -> local format with a leading 0. */
export function localPhone(raw, currency) {
  const cc = { GHS: '233', KES: '254' }[currency];
  let digits = String(raw ?? '').replace(/\D/g, '');
  if (digits.startsWith('00')) digits = digits.slice(2);
  if (digits.startsWith(cc)) digits = '0' + digits.slice(cc.length);
  if (!digits.startsWith('0')) digits = '0' + digits;
  if (!/^0\d{9}$/.test(digits)) throw new Error('Enter a mobile number like 055 123 4987');
  return digits;
}

/** Create the payment record. reference is your id at Paystack, fixed for life. */
export function newPayment({ orderId, amount, currency, phone, provider, email, now = Date.now() }) {
  if (!PROVIDERS[currency]) throw new Error('Currency must be GHS or KES');
  if (!PROVIDERS[currency].includes(provider)) throw new Error(`Provider must be one of ${PROVIDERS[currency].join(', ')} for ${currency}`);
  return {
    reference: `order-${orderId}-${randomUUID()}`,
    amount: toSubunits(amount), currency, phone: localPhone(phone, currency), provider, email,
    status: 'new', reason: null, createdAt: now,
  };
}

/** Starts the charge once. Returns { payment, displayText }: show displayText to the customer. */
export async function startPayment(payment, { save, api = paystack }) {
  if (payment.status !== 'new') return { payment, displayText: null }; // already started: never charge twice
  payment = await save({ ...payment, status: 'pending' }); // saved BEFORE the network call
  try {
    const data = await api.chargeMobileMoney(payment);
    if (String(data?.status) === 'failed') {
      return { payment: await save({ ...payment, status: 'failed', reason: data.message ?? 'failed' }), displayText: null };
    }
    // pay_offline (approve on the phone), or even "success": either way, verify decides.
    return { payment, displayText: data?.display_text ?? 'Approve the payment on your phone.' };
  } catch (err) {
    if (err instanceof PaystackError && !err.ambiguous) {
      return { payment: await save({ ...payment, status: 'failed', reason: err.body?.message ?? `HTTP_${err.status}` }), displayText: null };
    }
    return { payment, displayText: null }; // may exist at Paystack: reconcile decides
  }
}

/** Applies a verified transaction (from verify) to its payment. */
export async function settle(payment, tx, { save }) {
  if (!tx || tx.reference !== payment.reference) return payment; // not this payment
  if (['paid', 'review'].includes(payment.status)) return payment; // already final
  const status = String(tx.status ?? '').toLowerCase();
  if (status === 'success') {
    const exact = tx.currency === payment.currency && Number(tx.amount) === payment.amount;
    return save({ ...payment, status: exact ? 'paid' : 'review', reason: exact ? null : `PAID ${tx.amount} ${tx.currency}` });
  }
  if (['failed', 'abandoned', 'reversed'].includes(status)) {
    return save({ ...payment, status: 'failed', reason: tx.gateway_response ?? status });
  }
  return payment; // ongoing, pending, processing, queued: ask again later
}

/**
 * Handles a webhook whose signature you already checked.
 * Only data.reference is taken from the body; the outcome comes from verify.
 */
export async function handleWebhook(event, { find, save, api = paystack }) {
  const reference = event?.data?.reference;
  if (typeof reference !== 'string' || !reference) return null;
  const payment = await find(reference);
  if (!payment) return null; // not ours
  return settle(payment, await api.verify(reference), { save });
}

/** For a payment still pending: verifies it, and expires it if no transaction ever appeared. */
export async function reconcile(payment, { save, api = paystack, now = Date.now() }) {
  if (!['pending', 'expired'].includes(payment.status)) return payment;
  let tx;
  try {
    tx = await api.verify(payment.reference);
  } catch {
    return payment; // Paystack 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;
}

The rules:

  1. Save before charging. The reference is yours and is saved first, so a lost response never means a lost payment.
  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.reference is read from it; verify decides.
  4. The amount must match exactly. A mobile money charge is for a fixed amount, so anything else goes to review.
  5. Money received always wins. Only paid and review are final; a verified success settles an expired payment too.

Your save function should update the row by reference and refuse to change a payment that is paid or review, for example with WHERE status NOT IN ('paid', 'review'). Paystack’s own advice is the same: check you haven’t already given value for a transaction before you give it.

Phone numbers. localPhone turns +233 55 123 4987 into 0551234987, the local format with a leading 0 that Paystack’s Ghana example uses. Paystack’s docs show the Kenyan test number as +254 710 000 000; this guide sends Kenyan numbers in the same local format (0710000000), so confirm with the test number before going live. The African Phone Number Formatter shows the local format and network for any number.

The server

The signature is computed over the raw request body. If Express parses the JSON first and you re-serialise it, the bytes change and the signature never matches. So the webhook route uses express.raw().

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

const app = express();

// Checkout: create the payment and start the charge.
app.post('/pay', express.json(), async (req, res) => {
  const { orderId, amount, currency, phone, provider, email } = req.body; // in real code, load the amount from the order
  let created;
  try {
    created = newPayment({ orderId, amount, currency, phone, provider, email });
  } catch (err) {
    return res.status(422).json({ error: err.message });
  }
  const { payment, displayText } = await startPayment(created, { save });
  res.status(payment.status === 'failed' ? 400 : 202).json({ reference: payment.reference, status: payment.status, displayText });
});

// Webhook: the signature is computed over the RAW body, so don't parse it as JSON first.
app.post('/webhooks/paystack', express.raw({ type: '*/*' }), async (req, res) => {
  if (!signatureOk(req.body, req.get('x-paystack-signature'))) return res.sendStatus(401);
  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }
  try {
    await handleWebhook(event, { find, save });
    res.sendStatus(200);
  } catch (err) {
    console.error('Paystack webhook not processed', event?.data?.reference, err);
    res.sendStatus(500); // Paystack retries: every 3 minutes at first, then hourly
  }
});

// Your order page polls this until the status is final.
app.get('/payments/:reference', async (req, res) => {
  let payment = await find(req.params.reference);
  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. Paystack keeps resending until it gets one: in live mode every 3 minutes for the first 4 tries, then hourly for 72 hours. Answer quickly; long work in the handler leads to timeouts and repeats.

Run reconcile from a job every few minutes for payments that are pending and more than 3 minutes old (the 180-second window plus a margin), and hourly for a day for ones that are expired. Rate-limit the /payments/:reference route: each call can make a request to Paystack.

Test it without calling Paystack

This test replaces the client with a fake Paystack. It checks subunits and phone formats, that a payment is saved before it’s charged and charged once, that a forged webhook body changes nothing, that wrong amounts, currencies and references don’t pay, that failed and abandoned payments are final, that a timeout stays pending, and that the signature check is a real HMAC SHA512.

// payments.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { PaystackError, signatureOk } from './paystack.js';
import { handleWebhook, localPhone, newPayment, reconcile, settle, startPayment, toSubunits } 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.reference);
      if (old && ['paid', 'review'].includes(old.status)) return old; // final results never change
      rows.set(p.reference, p);
      return p;
    },
  };
}

function fakePaystack() {
  const txs = new Map(); // reference -> transaction, like Paystack's side
  return {
    txs,
    charges: [],
    async chargeMobileMoney(p) {
      this.charges.push(p);
      txs.set(p.reference, { reference: p.reference, amount: p.amount, currency: p.currency, status: 'ongoing' });
      return { reference: p.reference, status: 'pay_offline', display_text: 'Please complete authorization process on your mobile phone' };
    },
    async verify(ref) {
      return txs.get(ref) ?? null;
    },
  };
}

const order = (extra = {}) => newPayment({ orderId: 7, amount: '25.50', currency: 'GHS', phone: '+233 55 123 4987', provider: 'mtn', email: 'buyer@example.com', ...extra });

test('amounts go to Paystack in subunits, phones in local format', () => {
  assert.equal(toSubunits('25.50'), 2550);
  assert.equal(toSubunits('25.5'), 2550);
  assert.equal(toSubunits(100), 10000);
  assert.throws(() => toSubunits('1.234'));
  assert.equal(localPhone('+233 55 123 4987', 'GHS'), '0551234987');
  assert.equal(localPhone('0551234987', 'GHS'), '0551234987');
  assert.equal(localPhone('+254 710 000 000', 'KES'), '0710000000');
  assert.throws(() => localPhone('12345', 'GHS'));
  assert.throws(() => order({ provider: 'mpesa' }), /mtn, atl, vod/); // M-Pesa is Kenya only
  assert.throws(() => order({ currency: 'NGN' }), /GHS or KES/);
  assert.notEqual(order().reference, order().reference);
});

test('a payment is saved before the charge, charged once, and paid from verify', async () => {
  const db = makeDb();
  const api = fakePaystack();
  const real = api.chargeMobileMoney.bind(api);
  api.chargeMobileMoney = async (p) => {
    assert.equal(db.rows.get(p.reference)?.status, 'pending'); // saved BEFORE the charge
    return real(p);
  };
  const { payment, displayText } = await startPayment(order(), { ...db, api });
  assert.match(displayText, /mobile phone/);
  assert.deepEqual([api.charges[0].amount, api.charges[0].phone, api.charges[0].provider], [2550, '0551234987', 'mtn']);
  await startPayment(payment, { ...db, api }); // a double click charges nothing
  assert.equal(api.charges.length, 1);

  api.txs.get(payment.reference).status = 'success';
  const done = await handleWebhook({ event: 'charge.success', data: { reference: payment.reference } }, { ...db, api });
  assert.equal(done.status, 'paid');
});

test('the webhook body is never trusted: only verify counts', async () => {
  const db = makeDb();
  const api = fakePaystack();
  const { payment } = await startPayment(order(), { ...db, api });
  const forged = { event: 'charge.success', data: { reference: payment.reference, status: 'success', amount: 2550, currency: 'GHS' } };
  await handleWebhook(forged, { ...db, api }); // Paystack still says "ongoing"
  assert.equal(db.rows.get(payment.reference).status, 'pending');
  assert.equal(await handleWebhook({ data: { reference: 'not-ours' } }, { ...db, api }), null);
  assert.equal(await handleWebhook({ hello: 'world' }, { ...db, api }), null);
});

test('a different amount, currency or reference is not paid', async () => {
  const db = makeDb();
  const api = fakePaystack();
  const { payment } = await startPayment(order(), { ...db, api });
  const tx = { reference: payment.reference, amount: 255, currency: 'GHS', status: 'success' };
  assert.equal((await settle(payment, { ...tx, reference: 'someone-else', amount: 2550 }, db)).status, 'pending');
  assert.equal((await settle(payment, { ...tx, amount: 2550, currency: 'KES' }, db)).status, 'review');
  const { payment: second } = await startPayment(order(), { ...db, api });
  assert.equal((await settle(second, { ...tx, reference: second.reference }, db)).status, 'review'); // 2.55 instead of 25.50
});

test('failed and abandoned are final; ongoing waits; paid is never undone', async () => {
  const db = makeDb();
  const api = fakePaystack();
  for (const status of ['failed', 'abandoned']) {
    const { payment } = await startPayment(order(), { ...db, api });
    api.txs.get(payment.reference).status = status;
    assert.equal((await reconcile(payment, { ...db, api })).status, 'failed');
  }
  const { payment } = await startPayment(order(), { ...db, api });
  assert.equal((await reconcile(payment, { ...db, api })).status, 'pending'); // still ongoing
  api.txs.get(payment.reference).status = 'success';
  await reconcile(payment, { ...db, api });
  api.txs.get(payment.reference).status = 'reversed'; // a later answer can't undo it
  await reconcile({ ...payment, status: 'pending' }, { ...db, api });
  assert.equal(db.rows.get(payment.reference).status, 'paid');
});

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

  const offline = { ...fakePaystack(), async chargeMobileMoney() { return { status: 'failed', message: 'Declined' }; } };
  assert.equal((await startPayment(order(), { ...db, api: offline })).payment.status, 'failed');

  const api = fakePaystack();
  const real = api.chargeMobileMoney.bind(api);
  api.chargeMobileMoney = async (p) => {
    await real(p); // Paystack created it...
    throw new PaystackError('Paystack unreachable: timeout', { ambiguous: true }); // ...but we never heard back
  };
  const { payment } = await startPayment(order(), { ...db, api });
  assert.equal(payment.status, 'pending');
  api.txs.get(payment.reference).status = 'success';
  assert.equal((await reconcile(payment, { ...db, api })).status, 'paid');
});

test('no transaction expires after an hour, but a late success still counts', async () => {
  const db = makeDb();
  const api = { ...fakePaystack(), async chargeMobileMoney() { throw new PaystackError('Paystack 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');
  const expired = await reconcile(payment, { ...db, api, now: 2 * 60 * 60 * 1000 });
  assert.equal(expired.status, 'expired');
  const late = { reference: payment.reference, amount: 2550, currency: 'GHS', status: 'success' };
  assert.equal((await reconcile(expired, { ...db, api: { ...api, verify: async () => late } })).status, 'paid');
  const down = { ...api, async verify() { throw new PaystackError('down', { ambiguous: true }); } };
  const { payment: other } = await startPayment(order({ now: 0 }), { ...db, api });
  assert.equal((await reconcile(other, { ...db, api: down, now: 5 * 60 * 60 * 1000 })).status, 'pending'); // unreachable proves nothing
});

test('the webhook signature is an HMAC SHA512 of the raw body', () => {
  const body = Buffer.from('{"event":"charge.success","data":{"reference":"order-7-x"}}');
  const sig = createHmac('sha512', 'sk_test_secret').update(body).digest('hex');
  assert.equal(signatureOk(body, sig, 'sk_test_secret'), true);
  assert.equal(signatureOk(body, sig, 'sk_test_other'), false);
  assert.equal(signatureOk(Buffer.from(body.toString().replace('x', 'y')), sig, 'sk_test_secret'), false);
  assert.equal(signatureOk(body, undefined, 'sk_test_secret'), false);
  assert.equal(signatureOk(body, sig, ''), false);
  const unkeyed = createHmac('sha512', '').update(body).digest('hex');
  assert.equal(signatureOk(body, unkeyed, ''), false); // a missing secret must never accept anything
});

Run it with node --test.

Test in test mode

Paystack’s test-payments page lists these mobile money test numbers, and says neither needs a PIN or OTP:

Provider Test number
MTN (Ghana) 055 123 4987
M-Pesa (Kenya) +254 710 000 000

Start the server with your test key and a tunnel, create a payment with POST /pay, and watch the webhook arrive. Then stop the server before the webhook arrives, restart it, and watch GET /payments/:reference settle the payment through reconcile.

Common errors and fixes

Symptom Likely cause Fix
Webhooks get 401 The signature was computed over parsed JSON, or the wrong key Use express.raw() on the route and the secret key for the same mode
Amount 100 times too small Sent in cedis or shillings Send the subunit: multiply by 100
Order marked paid on status: true Read the top-level status Read data.status from verify
Failed payments stay pending No webhook is sent for failures Run reconcile; failures show up in verify
review Paid amount or currency differs from the order Check the transaction in the dashboard and decide

Going to production

  • Complete Paystack’s business activation for Ghana or Kenya, then switch to your live secret key.
  • Load the amount from your order on the server. Never take it from the request, as the /pay example does for brevity.
  • Store payments with reference as a unique key, and make save refuse to change a paid or review payment.
  • Run reconcile on a schedule, since webhooks only cover successful payments.
  • Send one small live payment on each provider you support before launch.

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

Selling in Uganda instead? See Flutterwave mobile money in Uganda. Going direct to Safaricom in Kenya? See M-Pesa STK push in Node.js.

Sending money back out to wallets? See Paystack mobile money payouts.

Frequently asked questions

Which mobile money providers does Paystack support?

Paystack documents mobile money for businesses in Ghana, Kenya and Côte d'Ivoire. The provider codes are mtn (Ghana and Côte d'Ivoire), atl (AT Money and Airtel Money, Ghana and Kenya), vod (Telecel, Ghana), mpesa (Kenya), and orange and wave (Côte d'Ivoire).

Is the Paystack amount in cedis or pesewas?

In the subunit. Paystack amounts are always sent in the currency's subunit: pesewas for GHS and cents for KES. GHS 25.50 is sent as 2550.

How do I verify a Paystack webhook?

The x-paystack-signature header is an HMAC SHA512 of the raw request body, keyed with your secret key. Compute it over the raw bytes before any JSON parsing, compare in constant time, and refuse the request if it doesn't match. Then confirm the payment with GET /transaction/verify/:reference.

What does pay_offline mean?

The charge was sent and the customer must approve it on their phone. Show them the display_text from the response. Paystack says the customer has 180 seconds; if no charge.success webhook arrives by then, verify the transaction.

Does Paystack send a webhook when a mobile money payment fails?

Paystack's verify-payments page says webhooks currently fire only for successful transactions. Find failures by verifying pending payments from your order page or a scheduled job.

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.