Accepting mobile money in Next.js
The App Router splits server and client in a way that decides where your API key may live, and route handlers parse bodies differently from Express.
Live in 14 markets: Benin, Cameroon, DR Congo, Gabon, Ivory Coast, Kenya, Mozambique, Republic of Congo, Rwanda, Senegal, Sierra Leone, Tanzania, Uganda and Zambia.
Install
Next.js is served by the Node and TypeScript client. There is no Next.js-specific package, and this page does not pretend otherwise: what is below is that client, used the way Next.js is written.
npm install @ultraner/sdkThis client is driven against the live sandbox in CI on every push.
The Next.js trap
In a Route Handler, `await req.json()` consumes the body and re-serialising it will never reproduce the bytes that were signed. Use `await req.text()` for the webhook, then parse after verifying.
Taking a payment
Two things this shows that are easy to get wrong anywhere. The amount is in whole units for most African currencies, so 5000 TZS is five thousand shillings and not fifty. And provideris the network's provider code, not its brand name: Mixx by Yas is still Tigo on the wire.
// app/api/pay/route.ts — a Route Handler, so the key stays server-side.
import { Ultraner } from '@ultraner/sdk';
import { randomUUID } from 'node:crypto';
const ultraner = new Ultraner(process.env.ULTRANER_API_KEY!);
export async function POST(req: Request) {
const { phone, amount } = await req.json();
const payment = await ultraner.payments.createMnoCharge({
amount, // whole shillings: TZS has no minor unit
currency: 'TZS',
provider: 'Vodacom', // the provider code, not the brand name
account_number: phone, // full international form, no plus
}, { idempotencyKey: randomUUID() });
// The prompt is on its way to the handset. Nothing has been paid yet.
return Response.json({ reference: payment.reference, status: payment.status });
}The response means the request was accepted, not that money moved. A mobile-money charge sends a prompt to the payer's handset and they may take a minute, or never answer. Which is why the next section is not optional.
Receiving the result
The webhook is how you learn a payment succeeded. Verify the signature over the raw bytes before trusting anything in the payload, acknowledge quickly, and do the work afterwards: a slow handler gets retried, and retries mean you will see the same event twice.
// app/api/webhooks/ultraner/route.ts
import crypto from 'node:crypto';
export async function POST(req: Request) {
// Raw text, not .json(). Re-serialising changes key order and whitespace,
// and the signature will never match.
const raw = await req.text();
const signature = req.headers.get('X-Ultraner-Signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.ULTRANER_WEBHOOK_SECRET!)
.update(raw)
.digest('hex');
// Constant-time: a plain === leaks the signature one byte at a time.
const valid = signature.length === expected.length && crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex'),
);
if (!valid) return new Response('bad signature', { status: 400 });
const event = JSON.parse(raw);
// Acknowledge fast. Slow handlers get retried, and retries mean you will
// see the same event id twice, so key your work on it.
void handle(event);
return new Response(null, { status: 200 });
}Before you go live
- Test keys need test paths. A uk_test_ key is rejected on a live URL by design. The client handles this; hand-rolled requests must use the sandbox path.
- Paying out is a different rail. Settling your balance to your own bank works from anywhere in the world. Paying into someone else's wallet is live in Tanzania only. The difference.
- Payouts need two more headers. X-Signature-Key names the person authorising it, and Idempotency-Key stops a retry paying twice.
- Never send Authorization: Bearer. The API takes X-API-Key. Bearer is for dashboard sessions and will fail.
Live in 14 markets. Every snippet on this page is checked in CI against the schemas the API validates with, so a field name here cannot drift from the one the API expects.