Aller au contenu

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

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

.env.local
WAJUB_API_KEY=sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…
WAJUB_WEBHOOK_SECRET=whsec_test_4f8c2b91d7e6a0f35c1dB7nY4hC6dF9j
APP_URL=http://localhost:3000

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

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

La table des commandes
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.

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);
}

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.

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.

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

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

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.

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

La réservation en SQL
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.

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} · {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.

Deux terminaux
npm run dev

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

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

Que pensez-vous de ce contenu ?