Integrating Moneroo into Next.js + Convex — from payment link to webhook
There's no complete Moneroo guide for Next.js + Convex out there.
I've integrated Moneroo into three different projects, including Pixel-Mart. Every time, I ran into the same problems, the same bugs, the same unanswered questions. This tutorial is what I wish I'd found the first time around.
Prerequisites: This tutorial uses the
monerooSDK — the official TypeScript SDK. If you don't have it yet: SDK overview →. To understand the mutation / action distinction in Convex (used throughout this guide): Convex — query, mutation, action →
We'll cover the full flow: → Create a payment link → Redirect the user → Receive the confirmation webhook → Verify the payment before crediting anything
What we're building
An order gets created. The user pays via Moneroo (Mobile Money, card...). Moneroo notifies us once it's done. We confirm.
This isn't a toy example — it's the exact flow running in production on Pixel-Mart.
Prerequisites
- A Moneroo account with an API key
- A Next.js project with Convex configured
- Environment variables:
MONEROO_SECRET_KEY,MONEROO_WEBHOOK_SECRET
1. The currency rule — before anything else
If you're working with XOF (CFA franc), remember this now:
XOF has no subunits. 5,000 FCFA = 5000. Not 500000.
European APIs expect amounts in cents (€5 → 500). Moneroo for XOF expects the raw amount (5,000 FCFA → 5000).
// convex/lib/currency.ts
const NO_SUBUNIT = ["XOF", "XAF", "GNF", "CDF"];
export function toMonerooAmount(amount: number, currency: string): number {
// XOF: send the amount as-is
// EUR: divide by 100 (cents → units)
return NO_SUBUNIT.includes(currency) ? amount : Math.round(amount / 100);
}2. The full flow
Frontend → createOrder mutation
→ initiatePayment action → Moneroo API
→ returns: payment_url
Frontend redirects to payment_url
Moneroo → webhook POST /api/webhooks/moneroo
→ httpAction verifies the signature
→ confirmPayment mutation
Mutation → Action → Moneroo → Webhook → Mutation. It's important to see this as a circuit, not a single operation.
3. Creating the order and initiating the payment
The mutation creates the order in the database and delegates the API call to an action.
// convex/orders/mutations.ts
export const create = mutation({
args: {
items: v.array(v.object({ productId: v.id("products"), quantity: v.number() })),
customerEmail: v.string(),
},
handler: async (ctx, args) => {
const orderId = await ctx.db.insert("orders", {
...args,
status: "pending",
createdAt: Date.now(),
});
// Delegate the Moneroo call to an action — never inside a mutation
await ctx.scheduler.runAfter(0, internal.payments.initiate, { orderId });
return orderId;
},
});The action talks to Moneroo and stores the payment link.
// convex/payments/actions.ts
"use node"; // required for HTTP calls
export const initiate = internalAction({
args: { orderId: v.id("orders") },
handler: async (ctx, { orderId }) => {
const order = await ctx.runQuery(internal.orders.get, { orderId });
const response = await fetch("https://api.moneroo.io/v1/payments/initialize", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MONEROO_SECRET_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: order.totalAmount, // in FCFA, not cents
currency: "XOF",
description: `Commande #${orderId}`,
customer: { email: order.customerEmail },
return_url: `${process.env.NEXT_PUBLIC_URL}/orders/${orderId}/confirmation`,
metadata: { orderId },
}),
});
const { data } = await response.json();
// Store the reference and the payment link
await ctx.runMutation(internal.orders.setPaymentData, {
orderId,
paymentReference: data.id,
paymentUrl: data.checkout_url,
});
},
});On the frontend, we wait for the link to be ready and then redirect:
// src/app/checkout/page.tsx
"use client";
export default function CheckoutPage() {
const createOrder = useMutation(api.orders.create);
const [orderId, setOrderId] = useState<Id<"orders"> | null>(null);
const order = useQuery(api.orders.get, orderId ? { orderId } : "skip");
// As soon as the link is available, redirect
useEffect(() => {
if (order?.paymentUrl) {
window.location.href = order.paymentUrl;
}
}, [order?.paymentUrl]);
async function handleCheckout() {
const id = await createOrder({ items, customerEmail });
setOrderId(id);
}
return <button onClick={handleCheckout}>Pay</button>;
}4. Receiving the webhook
Moneroo sends an HTTP notification when the payment succeeds. We receive it in a Convex httpAction.
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
import crypto from "node:crypto";
const http = httpRouter();
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") ?? "";
// Verify the signature BEFORE doing anything else
if (!verifySignature(rawBody, signature)) {
return new Response("Unauthorized", { status: 401 });
}
const event = JSON.parse(rawBody);
// Delegate processing to a mutation (an httpAction can't write directly)
await ctx.runMutation(internal.webhooks.handle, { event });
return new Response("OK", { status: 200 });
}),
});
function verifySignature(body: string, signature: string): boolean {
const expected = crypto
.createHmac("sha256", process.env.MONEROO_WEBHOOK_SECRET!)
.update(body)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
export default http;Two rules, in this exact order, no exceptions:
- Verify the signature
- Hand off to the mutation
Reverse the order and you're processing unverified data.
5. Processing the webhook — and verifying a second time
// convex/webhooks/mutations.ts
export const handle = internalMutation({
args: { event: v.any() },
handler: async (ctx, { event }) => {
if (event.type === "payment.success") {
// Find the order by payment reference
const order = await ctx.db
.query("orders")
.withIndex("by_payment_reference", (q) =>
q.eq("paymentReference", event.data.id)
)
.first();
if (!order || order.status !== "pending") return;
// Schedule an API verification before confirming
// → never credit an order on the webhook's word alone
await ctx.scheduler.runAfter(0, internal.payments.verifyAndConfirm, {
orderId: order._id,
});
}
},
});Why verify a second time? Because a webhook can be replayed, delayed, or forged. The verification API call confirms that the payment genuinely exists on Moneroo's side.
// convex/payments/actions.ts
export const verifyAndConfirm = internalAction({
args: { orderId: v.id("orders") },
handler: async (ctx, { orderId }) => {
const order = await ctx.runQuery(internal.orders.get, { orderId });
const response = await fetch(
`https://api.moneroo.io/v1/payments/${order.paymentReference}/verify`,
{
headers: { Authorization: `Bearer ${process.env.MONEROO_SECRET_KEY}` },
}
);
const { data } = await response.json();
if (data.status === "success") {
await ctx.runMutation(internal.orders.confirm, { orderId });
}
},
});6. Confirming the order
// convex/orders/mutations.ts
export const confirm = internalMutation({
args: { orderId: v.id("orders") },
handler: async (ctx, { orderId }) => {
await ctx.db.patch(orderId, {
status: "paid",
paidAt: Date.now(),
});
// Notify the seller, trigger fulfillment, etc.
await ctx.scheduler.runAfter(0, internal.notifications.orderPaid, { orderId });
},
});What we built
Order created → mutation
Payment initiated → action (via scheduler)
User pays on Moneroo
Moneroo notifies us → httpAction
Signature verified → mutation
Payment verified via API → action
Order confirmed → mutation
Each step does one thing. If one breaks, the others aren't affected — and you know exactly where to look.
Mistakes I made (that you're about to avoid)
— Sending 50000 instead of 5000 FCFA → the classic XOF-cents bug. Full write-up →
— Calling Moneroo directly from a mutation → blocked Convex transaction. Why mutation ≠ HTTP call →
— Confirming on the webhook without verifying → risk of false confirmation. httpAction and ctx.db →
— Forgetting "use node" in the action → crash on the crypto import
— Not configuring httpRouter in convex/http.ts → webhook never received
— Exposing confirmPayment as a public mutation → callable from the browser console. internal vs public →
If you're building something with Moneroo + Convex and get stuck on something specific — let me know.
→ The Moneroo SDK that simplifies all this code: SDK overview → Driving Moneroo from Claude in natural language: From SDK to MCP → The financial rules that protect seller balances: Rule F-01