Next.js checkout
A complete App Router checkout, from the Server Action to the verified webhook.
This recipe builds a Hosted Checkout in a Next.js App Router project: a form, a Server Action that creates the payment, a return page and a webhook route that fulfils the order. The reasoning behind every step is in Accept a payment.
Looking for the embedded modes instead?
This recipe redirects to a page Wajub hosts. To keep the customer inside your own layout, see React / Next.js, three modes, which covers the embedded component and the custom-fields build. The server half of this recipe is identical for all three.
What you are building
Prerequisites
A Next.js 15 or 16 project using the App Router, and sandbox keys from your Dashboard.
npm install @wajub/node1. Environment
Only one key is needed, and it is a server variable. Do not give it a NEXT_PUBLIC_ prefix: that
prefix is an instruction to inline the value into the browser bundle.
WAJUB_API_KEY=sk_test.xxxxxxxxxxxxxxxx…
WAJUB_WEBHOOK_SECRET=whsec_test_xxxxxxxxxxxxxxxx
APP_URL=http://localhost:3000The webhook secret comes from the endpoint you register in the Dashboard, and it is what step 6 verifies signatures against.
2. One client for the whole app
Instantiate the SDK once and import it everywhere. Passing webhookSecret here is what lets
webhooks.constructEvent() work later without repeating the secret.
import { Wajub } from '@wajub/node';
export const wajub = new Wajub({
apiKey: process.env.WAJUB_API_KEY!,
webhookSecret: process.env.WAJUB_WEBHOOK_SECRET!,
});3. The order, which is the actual source of truth
The checkout needs somewhere to record what was bought before anyone pays. Use your ORM of choice; what matters is the shape.
CREATE TABLE orders (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'XAF',
status TEXT NOT NULL DEFAULT 'awaiting_payment',
payment_id TEXT UNIQUE,
fulfilled_at TIMESTAMPTZ
);payment_id is the only link back to Wajub, and fulfilled_at is what stops the order being
delivered twice when both the browser and the webhook report success.
4. The Server Action
The 'use server' directive is a boundary the bundler respects: nothing in this file reaches the
browser, so the key is safe here.
Read the amount from the order, never from the form. A client that can name its own price will.
'use server';
import { redirect } from 'next/navigation';
import { wajub } from '@/lib/wajub';
import { orders } from '@/lib/orders';
export async function checkout(orderId: string) {
const order = await orders.find(orderId);
if (!order) throw new Error('Unknown order');
const payment = await wajub.payments.create(
{
amount: order.amount,
currency: order.currency,
customer: { email: order.email },
reference: order.id,
description: `Order ${order.id}`,
callback: `${process.env.APP_URL}/orders/${order.id}/return`,
},
{ idempotencyKey: order.id },
);
await orders.update(order.id, { payment_id: payment.id });
redirect(payment.authorization_url);
}Two lines carry more weight than they look.
The idempotencyKey derived from the order id means a customer who double-clicks gets the same
payment back rather than a second one. The key returns the original for good, not for a day, and
the same key with a different amount, currency or reference is refused outright. It also means this
order can only ever have this one payment: to let a customer try again after a failure, put an
attempt number in the key.
Storing payment.id before the redirect matters. If the process dies in between, an order with
no payment_id is recoverable; a payment nobody can match to an order is not.
`redirect()` throws by design
In the App Router, redirect() works by throwing a special error that the framework catches. Never
call it inside a try block that swallows exceptions, or the redirect silently becomes a caught
error and the customer stays on the form.
5. The form
The form posts an order id and nothing else. There is no amount field, because there is nothing for the customer to decide at this point.
import { checkout } from '@/app/actions/checkout';
import { orders } from '@/lib/orders';
export default async function CheckoutPage({
params,
}: {
params: Promise<{ orderId: string }>;
}) {
const { orderId } = await params;
const order = await orders.find(orderId);
if (!order) return <p>Order not found.</p>;
async function pay() {
'use server';
await checkout(orderId);
}
return (
<main className="mx-auto max-w-md p-6">
<h1 className="text-2xl font-bold">Checkout</h1>
<p className="mt-2 text-sm text-gray-600">
Order {order.id} · {order.amount} {order.currency}
</p>
<form action={pay} className="mt-6">
<button
type="submit"
className="w-full rounded-lg bg-black px-4 py-2.5 text-sm font-semibold text-white"
>
Pay with Wajub
</button>
</form>
</main>
);
}`params` is a Promise from Next.js 15 onwards
params and searchParams became asynchronous in Next.js 15. Awaiting them, as above, is correct
on 15 and 16 and is what a project created today needs.
6. The webhook route
This is the only endpoint that decides an order has been paid. Verification needs the raw body, so
read it with req.text() and never with req.json(): a parsed and re-serialised body no longer
matches the signature. constructEvent is only the check here; once the signature holds, the route parses
the body (id, event, data) with its own type.
import { NextRequest, NextResponse } from 'next/server';
import { wajub } from '@/lib/wajub';
import { fulfillPayment, markOrderFailed } from '@/lib/fulfil';
type WajubEvent = {
id: string;
event: string;
data: { id: string; failure_reason?: string };
};
export async function POST(req: NextRequest) {
const rawBody = await req.text();
try {
wajub.webhooks.constructEvent(
rawBody,
req.headers.get('x-wajub-signature') ?? '',
req.headers.get('x-wajub-timestamp') ?? '',
);
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
const event = JSON.parse(rawBody) as WajubEvent;
switch (event.event) {
case 'payment.succeeded':
await fulfillPayment(event.data.id);
break;
case 'payment.failed':
case 'payment.expired':
case 'payment.cancelled':
await markOrderFailed(event.data.id, event.data.failure_reason);
break;
}
return NextResponse.json({ received: true });
}constructEvent also rejects a timestamp more than 300 seconds away from now, which stops a captured
request being replayed at you later.
Answer within ten seconds
A delivery that does not get a 2xx in ten seconds counts as failed and is redelivered for up to 72
hours. If fulfilment is slow, push a
job here and return immediately. Express webhook handler shows that
shape in full.
7. Fulfilment, written once
Both the webhook and the return page call this. It re-reads the payment from Wajub rather than trusting its caller, checks the amount as well as the status, and claims the order before doing any work.
import { wajub } from '@/lib/wajub';
import { deliver } from '@/lib/deliver';
import { orders } from '@/lib/orders';
export async function fulfillPayment(paymentId: string) {
const payment = await wajub.payments.retrieve(paymentId);
if (payment.status !== 'succeeded') return;
const order = await orders.findByPaymentId(paymentId);
if (!order) return;
if (Number(payment.amount) !== Number(order.amount) || payment.currency !== order.currency) {
console.error('Amount mismatch on', paymentId);
return;
}
const claimed = await orders.claim(order.id);
if (!claimed) return;
await deliver(order);
await orders.update(order.id, { status: 'fulfilled', fulfilled_at: new Date() });
}
export async function markOrderFailed(paymentId: string, reason?: string) {
const order = await orders.findByPaymentId(paymentId);
if (order) await orders.update(order.id, { status: 'payment_failed', failure_reason: reason });
}orders.claim is a single conditional update, and it is the whole concurrency story.
UPDATE orders
SET status = 'paid'
WHERE id = $1
AND fulfilled_at IS NULL
AND status <> 'paid';One row updated means you won the race. Zero rows means the other channel got there first, and doing nothing is the correct response.
8. The return page
The customer lands back on your callback. Don't read the parameters Wajub appends to it: the URL
already carries the order id in its path, so look the order up and ask the API.
import { wajub } from '@/lib/wajub';
import { orders } from '@/lib/orders';
import { fulfillPayment } from '@/lib/fulfil';
export default async function ReturnPage({
params,
}: {
params: Promise<{ orderId: string }>;
}) {
const { orderId } = await params;
const order = await orders.find(orderId);
if (!order?.payment_id) return <p>Order not found.</p>;
const payment = await wajub.payments.retrieve(order.payment_id);
if (payment.status === 'succeeded') {
await fulfillPayment(order.payment_id);
return (
<main className="mx-auto max-w-md p-6 text-center">
<h1 className="text-2xl font-bold">Payment confirmed</h1>
<p className="mt-2">
{payment.amount} {payment.currency}
</p>
</main>
);
}
if (['failed', 'expired', 'cancelled'].includes(payment.status)) {
return (
<main className="mx-auto max-w-md p-6 text-center">
<h1 className="text-2xl font-bold">Payment not completed</h1>
<p className="mt-2">Nothing was charged. You can try again.</p>
</main>
);
}
return (
<main className="mx-auto max-w-md p-6 text-center">
<h1 className="text-2xl font-bold">Waiting for confirmation</h1>
<p className="mt-2">
The prompt has been sent to your phone. Confirming it still works, and we will email you as
soon as it lands.
</p>
</main>
);
}That third branch is the one people forget. A Mobile Money payment is confirmed by a human on a handset, so a customer coming back before they have typed their PIN is normal, not an error.
9. Run it locally
The webhook route has to be reachable from Wajub. Rather than deploying, forward events to your dev server with the CLI.
npm run dev
wajub listen --forward-to localhost:3000/api/webhooks/wajubThen pay with a sandbox number. The last six digits choose the outcome: +237670000000 succeeds and
+237670000002 is refused by the operator, which is the pair you need to exercise both branches of
step 6. The full table is in Test scenarios.
Prove the handler is idempotent before you ship
Replay the same payment.succeeded from
Konsole two or three times. If orders.claim is right, the
order is delivered once and the extra deliveries do nothing at all.
Related pages
- Accept a paymentThe reasoning behind every step of this recipe.
- React / Next.js, three modesKeep the customer on your page with the embedded component.
- Express webhook handlerThe production shape of step 6, with queueing and deduplication.
- Signature verificationWhat constructEvent does, for a stack without an SDK.
- Going liveThe checklist before switching to production keys.