Checkout avec Next.js App Router
Un checkout App Router complet, de la Server Action au webhook vérifié.
Cette recette construit un checkout fonctionnel dans un projet Next.js App Router : un formulaire, une Server Action qui crée le paiement, une page de retour et une route de webhook qui livre la commande.
Elle utilise le Checkout hébergé, le mode dans lequel Wajub affiche l'écran de paiement. Le raisonnement de chaque étape se trouve dans Accepter un paiement, guide complet. Cette page fournit le code.
Vous cherchez plutôt les modes intégrés ?
Cette recette redirige vers une page hébergée par Wajub. Pour conserver le client dans votre propre layout, consultez React et Next.js, trois modes, qui présente le composant intégré et les champs personnalisés. La partie serveur de cette recette est identique dans les trois modes.
Ce que vous allez construire
Prérequis
Un projet Next.js 15 ou 16 avec App Router et des clés de sandbox provenant de votre Dashboard.
npm install @wajub/node1. Environnement
Une seule clé est nécessaire. Il s'agit d'une variable serveur. Ne lui ajoutez pas le préfixe NEXT_PUBLIC_, qui demande d'intégrer la valeur au bundle du navigateur.
WAJUB_API_KEY=sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…
WAJUB_WEBHOOK_SECRET=whsec_test_4f8c2b91d7e6a0f35c1dB7nY4hC6dF9j
APP_URL=http://localhost:3000Le secret de webhook provient de l'endpoint enregistré dans le Dashboard. L'étape 6 l'utilise pour vérifier les signatures.
2. Un client unique pour toute l'application
Instanciez le SDK une seule fois, puis importez-le partout. La transmission de webhookSecret ici permet à webhooks.constructEvent() de fonctionner plus tard sans répéter le secret.
import { Wajub } from '@wajub/node';
export const wajub = new Wajub({
apiKey: process.env.WAJUB_API_KEY!,
webhookSecret: process.env.WAJUB_WEBHOOK_SECRET!,
});3. La commande comme véritable source de vérité
Le checkout doit enregistrer l'achat avant tout paiement. Utilisez l'ORM de votre choix. Seule la structure compte.
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 est le seul lien vers Wajub. fulfilled_at empêche une double livraison lorsque le navigateur et le webhook signalent tous deux une réussite.
4. La Server Action
La directive 'use server' définit une frontière respectée par l'outil de création du bundle. Rien de ce fichier n'atteint le navigateur. La clé y est donc protégée.
Lisez le montant depuis la commande, jamais depuis le formulaire. Un client capable de définir son propre prix finira par le faire.
'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);
}Deux lignes sont plus importantes qu'elles n'en ont l'air.
La valeur idempotencyKey dérivée de l'identifiant de commande garantit qu'un double clic renvoie le même paiement au lieu d'en créer un second. Pendant vingt-quatre heures, la clé renvoie le paiement initial. La même clé avec un payload différent est refusée.
Vous devez enregistrer payment.id avant la redirection. Si le processus s'arrête entre les deux actions, une commande sans payment_id reste récupérable. Un paiement impossible à associer à une commande ne l'est pas.
`redirect()` produit volontairement une exception
Dans App Router, redirect() produit une erreur spéciale interceptée par le framework. Ne l'appelez jamais dans un bloc try qui absorbe les exceptions. La redirection deviendrait silencieusement une erreur interceptée et le client resterait sur le formulaire.
5. Le formulaire
Le formulaire envoie uniquement un identifiant de commande. Il ne possède aucun champ de montant, car le client n'a rien à décider à ce stade.
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` est une Promise depuis Next.js 15
params et searchParams sont devenus asynchrones dans Next.js 15. Les attendre comme ci-dessus est correct dans les versions 15 et 16, et nécessaire dans un nouveau projet.
6. La route du webhook
Il s'agit du seul endpoint qui décide qu'une commande est payée. La vérification exige le corps brut. Lisez-le avec req.text(), jamais avec req.json(). Un corps analysé puis sérialisé à nouveau ne correspond plus à la signature.
import { NextRequest, NextResponse } from 'next/server';
import { wajub } from '@/lib/wajub';
import { fulfillPayment, markOrderFailed } from '@/lib/fulfil';
export async function POST(req: NextRequest) {
const rawBody = await req.text();
let event;
try {
event = 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 });
}
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 effectue trois opérations : recalculer le HMAC de timestamp.body, le comparer en temps constant et refuser un horodatage qui s'écarte de plus de 300 secondes de l'heure actuelle. Cette dernière opération empêche la répétition ultérieure d'une requête interceptée.
Répondez en moins de dix secondes
Une livraison qui ne reçoit pas de code 2xx en dix secondes est considérée comme échouée. Elle est tentée cinq nouvelles fois après trente secondes, une minute, cinq minutes, dix minutes et une heure. Si la livraison de la commande est lente, ajoutez une tâche à la file d'attente et répondez immédiatement. Handler de webhook avec Express présente cette structure en détail.
7. Une seule implémentation de la livraison
Le webhook et la page de retour appellent tous deux cette fonction. Elle relit le paiement depuis Wajub au lieu de faire confiance à l'appelant, vérifie le montant en plus du statut et réserve la commande avant tout traitement.
import { wajub } from '@/lib/wajub';
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 est une mise à jour conditionnelle unique qui gère à elle seule les accès simultanés.
UPDATE orders
SET status = 'paid'
WHERE id = $1
AND fulfilled_at IS NULL
AND status <> 'paid';Une ligne mise à jour signifie que vous pouvez continuer. Zéro ligne signifie que l'autre canal est arrivé en premier. La bonne réponse consiste alors à ne rien faire.
8. La page de retour
Le client revient sur votre callback. Wajub ajoute ses propres paramètres, dont les noms prêtent à confusion : reference contient l'identifiant du paiement Wajub, trxref la référence envoyée et status la valeur communiquée au navigateur. Ne lisez aucun de ces paramètres.
L'URL contient déjà l'identifiant de la commande dans son chemin. Recherchez la commande et interrogez l'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} · {payment.channel}
</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>
);
}La troisième branche est souvent oubliée. Une personne confirme un paiement Mobile Money sur son téléphone. Il est donc normal qu'un client revienne avant d'avoir saisi son code PIN. Ce n'est pas une erreur.
9. Exécuter le projet en local
Wajub doit pouvoir atteindre la route du webhook. Au lieu de déployer, transférez les événements vers votre serveur de développement avec la CLI.
npm run dev
wajub listen --forward-to localhost:3000/api/webhooks/wajubPayez ensuite avec un numéro de sandbox. Les six derniers chiffres déterminent le résultat : +237670000000 réussit et +237670000001 échoue pour fonds insuffisants. Cette paire permet de tester les deux branches de l'étape 6. La table complète se trouve dans Scénarios de test.
Vérifiez l'idempotence du handler avant le déploiement
Relancez le même événement payment.succeeded depuis Konsole deux ou trois fois. Si orders.claim est correct, la commande est livrée une seule fois et les livraisons supplémentaires ne font rien.
Pages associées
- Accepter un paiement, guide completLe raisonnement derrière chaque étape de cette recette.
- React et Next.js, trois modesConserver le client sur votre page avec le composant intégré.
- Handler de webhook avec ExpressL'architecture de production de l'étape 6, avec file d'attente et déduplication.
- Vérification de signatureLe fonctionnement de constructEvent pour une stack dépourvue de SDK.
- Passer en liveLa checklist à suivre avant de passer aux clés de production.