Integrating Mobile Money payments on your Beninese website — what the button hides
On my very first MTN Mobile Money integration, I sent 50,000 instead of 5,000 FCFA.
The customer nearly paid ten times the price of his order.
That's not a code bug. It's a monetary-culture bug. European APIs expect amounts in cents (€5 = 500). XOF has no cents. 5,000 FCFA equals 5000. Not 500000.
No Stripe tutorial tells you that.
Mobile Money isn't Stripe
Stripe is synchronous. You call the API, you get a response, you confirm the order.
Mobile Money is asynchronous. The customer initiates the payment on your site. They get a USSD notification on their phone. They confirm — or they don't. The operator tells you later, via webhook.
Later can mean 3 seconds. Or 45 seconds. Or never, if the customer bails out halfway through.
Your site has to handle all of that without blocking, without crediting what hasn't been paid, without losing valid payments that arrive late.
That's why "adding Mobile Money" isn't a configuration. It's a circuit.
The real flow — step by step
On Pixel-Mart and PLR Library, here's what happens when a customer clicks "Pay":
1. Frontend → creates the order in the database (mutation)
2. Mutation → schedules the API call (action)
3. Action → calls the Moneroo API → receives a checkout_url
4. Checkout_url stored in the database
5. Frontend watches the order → as soon as checkout_url arrives, redirects
6. Customer pays on the Moneroo page (USSD MTN / Moov)
7. Moneroo → sends a webhook POST to your server
8. Your server → verifies the HMAC signature
9. → calls the verification API (never trust the webhook alone)
10. → if confirmed: order moves to "paid"
Ten steps. Any of them can fail.
Why the mutation can't call the API
If you're using Convex (and it's the right call for this kind of flow), you'll quickly hit a non-negotiable constraint:
Convex mutations cannot make HTTP calls.
The mutation creates the order. It delegates the API call to an internal action via the scheduler.
// convex/orders/mutations.ts
export const create = mutation({
handler: async (ctx, args) => {
const orderId = await ctx.db.insert("orders", {
...args,
status: "pending",
});
// Never directly inside the mutation
await ctx.scheduler.runAfter(0, internal.payments.initiate, { orderId });
return orderId;
},
});The action makes the network call:
// convex/payments/actions.ts
"use node"; // required — crypto doesn't work without it
export const initiate = internalAction({
handler: async (ctx, { orderId }) => {
const order = await ctx.runQuery(internal.orders.get, { orderId });
const res = await fetch("https://api.moneroo.io/v1/payments/initialize", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.MONEROO_SECRET_KEY}` },
body: JSON.stringify({
amount: order.totalAmount, // XOF: raw amount, no cents
currency: "XOF",
customer: { email: order.customerEmail },
return_url: `${process.env.NEXT_PUBLIC_URL}/orders/${orderId}/confirmation`,
metadata: { orderId },
}),
});
const { data } = await res.json();
await ctx.runMutation(internal.orders.setPaymentData, {
orderId,
paymentUrl: data.checkout_url,
});
},
});The frontend watches the order in real time via useQuery and redirects as soon as the link is available. No polling. No setTimeout. Convex handles the reactivity.
The XOF rule — written into the code
This bug is common enough to deserve its own dedicated function.
// convex/lib/currency.ts
const NO_SUBUNIT = ["XOF", "XAF", "GNF", "CDF"];
export function toMonerooAmount(amount: number, currency: string): number {
return NO_SUBUNIT.includes(currency) ? amount : Math.round(amount / 100);
}toMonerooAmount(5000, "XOF") returns 5000.
toMonerooAmount(500, "EUR") returns 5.
The rule lives in the code, not in your memory.
Verify the webhook — twice, not once
A webhook can be:
- Replayed by the operator (network delay, automatic retry)
- Received twice for the same payment
- Forged by a third party if you don't check the signature
The signature comes first, always.
// convex/http.ts
http.route({
path: "/webhooks/moneroo",
method: "POST",
handler: httpAction(async (ctx, req) => {
const rawBody = await req.text();
const signature = req.headers.get("x-moneroo-signature") ?? "";
if (!verifySignature(rawBody, signature)) {
return new Response("Unauthorized", { status: 401 });
}
const event = JSON.parse(rawBody);
await ctx.runMutation(internal.webhooks.handle, { event });
return new Response("OK", { status: 200 });
}),
});
function verifySignature(body: string, sig: string): boolean {
const expected = crypto
.createHmac("sha256", process.env.MONEROO_WEBHOOK_SECRET!)
.update(body)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Then, even if the signature is valid, we don't credit the account on the webhook's word alone. We call the API again to verify that the payment actually exists.
// Inside the webhook mutation
if (event.type === "payment.success") {
const order = await ctx.db
.query("orders")
.withIndex("by_payment_reference", (q) =>
q.eq("paymentReference", event.data.id)
)
.first();
// Idempotency: ignore if already processed
if (!order || order.status !== "pending") return;
await ctx.scheduler.runAfter(0, internal.payments.verifyAndConfirm, {
orderId: order._id,
});
}Two passes. The signature says the sender is Moneroo. The API call says the payment is real.
The mistakes I made before this circuit existed
The cents bug. Amount multiplied by 100 on XOF. Fixed with toMonerooAmount().
The mutation calling HTTP. Convex fails silently. Fixed with the mutation → scheduler → action pattern.
The webhook processed without verification. Anyone can POST to your endpoint. Fixed with HMAC.
The double confirmation. Webhook received twice → order confirmed twice → stock decremented twice. Fixed by checking status !== "pending" before processing.
Forgotten "use node". The action imports crypto → crash. Fixed by putting the directive on the first line of the file.
The real fees — the question everyone avoids
When people ask "how much does it cost to accept Mobile Money," the answer usually given is a percentage commission. That's incomplete.
Fees exist at several stacked levels:
- The operator (MTN, Moov) takes its cut on the transaction.
- The aggregator or orchestrator (FedaPay, Moneroo, KKiaPay, CinetPay) takes a commission for integration and routing.
- Development time to handle the error cases above doesn't show up on any invoice, but it's the highest cost if nobody handles it properly — a mismanaged payment (double credit, lost payment) costs more than a commission of a few percentage points.
The exact rates and the criteria for choosing between a direct aggregator and an orchestrator are detailed in the payment comparison article. The short rule: single-country, simple needs → direct aggregator (FedaPay, KKiaPay); multi-country or varied payouts → orchestrator (Moneroo).
What it costs to not handle these cases
A valid payment wrongly rejected: the customer doesn't come back.
An order credited without a real payment: you ship at a loss.
A webhook processed twice: wrong stock, duplicate invoice, confused customer.
On Pixel-Mart, handling these edge cases took as much time as the rest of the checkout flow. That's not over-engineering. That's what separates a real online store from a storefront that only sometimes collects money.
Mobile Money isn't hard. It's just asynchronous in a world that thinks synchronously.
Once the circuit is in place — mutation, action, webhook, verification — it runs without intervention. And MTN payments at 3am arrive just like all the others.
→ The complete tutorial with all the code: Next.js + Convex + Moneroo → Why Mobile Money isn't optional for a Beninese e-commerce site → What payment integration really costs in a quote