Next.js Checkout Flow
Build a complete payment checkout with Next.js App Router, server actions, and Wajub hosted payment page — production-ready in minutes.
This recipe walks through building a complete checkout flow with Next.js 14+ App Router. You'll create a server action that initializes a payment and redirects the user to Wajub's hosted payment page, plus a return handler that verifies the transaction.
Prerequisites
- A Wajub account with sandbox API keys
- Node.js 18+ and a Next.js project
npx create-next-app@latest my-store --typescript --app
cd my-storeThere is no official Wajub SDK yet — this recipe calls the REST API directly with fetch.
1. Set up environment variables
Create .env.local:
WAJUB_PUBLIC_KEY=pk_test.xxxxxxxxxxxxxxxx
WAJUB_SECRET_KEY=sk_test.xxxxxxxxxxxxxxxx2. Create a checkout server action
Create app/actions/checkout.ts:
'use server';
import { redirect } from 'next/navigation';
export async function checkout(formData: FormData) {
const amount = Number(formData.get('amount'));
const email = formData.get('email') as string;
const orderId = `ORDER-${Date.now()}`;
const res = await fetch('https://api.wajub.com/payments', {
method: 'POST',
headers: {
Authorization: process.env.WAJUB_PUBLIC_KEY!,
'Content-Type': 'application/json',
'Idempotency-Key': orderId,
},
body: JSON.stringify({
amount,
currency: 'XAF',
email,
reference: orderId,
description: `Order ${orderId}`,
callback: `${process.env.NEXT_PUBLIC_APP_URL}/payment/return`,
metadata: { order_id: orderId },
}),
});
if (!res.ok) {
console.error('Checkout failed:', await res.text());
throw new Error('Failed to initialize payment');
}
const { authorization_url } = await res.json();
// Store the transaction id for later verification
// (in production, save to your database)
redirect(authorization_url);
}4. Create the checkout page
Create app/checkout/page.tsx:
import { checkout } from '@/app/actions/checkout';
export default function CheckoutPage() {
return (
<main className="mx-auto max-w-md p-6">
<h1 className="text-2xl font-bold">Checkout</h1>
<form action={checkout} className="mt-6 space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input
type="email"
name="email"
id="email"
required
className="mt-1 block w-full rounded-md border px-3 py-2"
placeholder="customer@example.com"
/>
</div>
<div>
<label htmlFor="amount" className="block text-sm font-medium">Amount (XAF)</label>
<input
type="number"
name="amount"
id="amount"
required
min={100}
className="mt-1 block w-full rounded-md border px-3 py-2"
placeholder="25000"
/>
</div>
<button
type="submit"
className="w-full rounded-lg bg-brand px-4 py-2.5 text-sm font-semibold text-white hover:bg-brand-600"
>
Pay with Wajub
</button>
</form>
</main>
);
}5. Handle the return
Create app/payment/return/page.tsx:
export default async function PaymentReturnPage({
searchParams,
}: {
searchParams: { reference?: string; status?: string };
}) {
if (!searchParams.reference) {
return <p>No transaction reference found.</p>;
}
const res = await fetch(`https://api.wajub.com/payments/${searchParams.reference}`, {
headers: { Authorization: process.env.WAJUB_SECRET_KEY! },
});
if (!res.ok) {
return <p className="text-danger">Failed to verify payment.</p>;
}
const { transaction } = await res.json();
return (
<main className="mx-auto max-w-md p-6 text-center">
{transaction.status === 'succeeded' ? (
<>
<h1 className="text-2xl font-bold text-success">Payment successful!</h1>
<p className="mt-2 text-muted-foreground">
ID: {transaction.id}<br />
Amount: {transaction.amount} {transaction.currency}
</p>
</>
) : (
<h1 className="text-2xl font-bold text-warning">
Payment pending — we'll confirm via webhook
</h1>
)}
</main>
);
}Always verify server-side
Never trust the status query parameter on its own. Always call GET /payments/{id}
server-side to confirm the transaction before fulfilling the order.
6. Add webhook handling (production)
For production, add a webhook endpoint that listens for payment.succeeded events:
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
function verifySignature(rawBody: string, timestamp: string, signatureHeader: string) {
const expected = 'v1=' + crypto
.createHmac('sha256', process.env.WAJUB_WEBHOOK_SECRET!)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
export async function POST(req: NextRequest) {
const rawBody = await req.text();
const signature = req.headers.get('x-wajub-signature');
const timestamp = req.headers.get('x-wajub-timestamp');
if (!signature || !timestamp || !verifySignature(rawBody, timestamp, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
const event = JSON.parse(rawBody);
switch (event.type) {
case 'payment.succeeded':
await fulfillOrder(event.data.id);
break;
case 'payment.failed':
await handleFailedPayment(event.data.id);
break;
}
return NextResponse.json({ received: true }, { status: 200 });
}
async function fulfillOrder(id: string) {
// Fulfill the order in your database
console.log(`Order ${id} paid — fulfilling...`);
}
async function handleFailedPayment(id: string) {
// Notify the customer, etc.
console.log(`Payment ${id} failed.`);
}