Skip to content

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/node

1. 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.

.env.local
WAJUB_API_KEY=sk_test.xxxxxxxxxxxxxxxx…
WAJUB_WEBHOOK_SECRET=whsec_test_xxxxxxxxxxxxxxxx
APP_URL=http://localhost:3000

The 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.

lib/wajub.ts
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.

The orders table
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.

app/actions/checkout.ts
'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.

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.

app/checkout/[orderId]/page.tsx
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.

app/api/webhooks/wajub/route.ts
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.

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.

lib/fulfil.ts
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.

The claim, in SQL
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.

app/orders/[orderId]/return/page.tsx
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.

Two terminals
npm run dev

wajub listen --forward-to localhost:3000/api/webhooks/wajub

Then 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.

What did you think of this content?