Aller au contenu

Accepter un paiement, guide complet

Le cycle de vie d'une commande autour d'un paiement : l'enregistrer, le vérifier et la livrer une seule fois.

Appeler l'API est la partie facile. Quatre requêtes suffisent pour obtenir un paiement. Le Démarrage rapide Paiements les présente ligne par ligne. Ce guide traite de l'autre moitié : la commande dans votre propre base de données, qui doit rester correcte quelles que soient les actions du client, du navigateur et du réseau.

Tout ce qui suit répond à une seule règle. Votre commande est livrée exactement une fois, et uniquement après la confirmation des fonds par Wajub.

Vous conservez deux enregistrements. Le vôtre est la commande : ce qui a été acheté, par qui et si la livraison a eu lieu. Wajub conserve le paiement : le montant encaissé, l'opérateur utilisé et le résultat.

Une seule valeur relie les deux enregistrements : l'id du paiement. Enregistrez-le sur la commande dès la création du paiement. Vous pourrez ainsi répondre à toutes les questions ultérieures.

Le lien inverse existe aussi. Vous définissez le champ reference sur le paiement, généralement avec votre numéro de commande, et Wajub le renvoie à chaque lecture. Il aide une personne à lire le Dashboard, mais ne possède aucune contrainte d'unicité ni endpoint de recherche. Ne construisez donc aucune logique dessus.

1. Attribuer un statut à la commande avant tout appel

La commande existe avant le paiement. Créez-la d'abord avec un état qui indique clairement qu'aucun paiement ni aucune livraison n'a eu lieu.

La séparation entre paid et fulfilled dans le schéma rend le reste de ce guide possible. La confirmation des fonds et la livraison des marchandises sont deux événements distincts qui peuvent échouer séparément. Les regrouper dans une seule colonne vous empêche de savoir ce qui a déjà été effectué si un plantage survient pendant la livraison.

Voici la structure de la table. Deux colonnes assurent l'essentiel : payment_id, qui établit le lien avec Wajub, et fulfilled_at, qui empêche une double livraison.

La table des commandes
CREATE TABLE orders (
  id              BIGSERIAL PRIMARY KEY,
  reference       VARCHAR(64) NOT NULL UNIQUE,
  amount          NUMERIC(12, 2) NOT NULL,
  currency        CHAR(3) NOT NULL,
  status          VARCHAR(32) NOT NULL DEFAULT 'awaiting_payment',
  payment_id      VARCHAR(64) UNIQUE,
  attempts        INTEGER NOT NULL DEFAULT 0,
  fulfilled_at    TIMESTAMPTZ,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX orders_payment_id_idx ON orders (payment_id);

2. Créer le paiement et enregistrer son identifiant

Passez maintenant à l'appel d'API. Envoyez le montant, la devise, le client et l'adresse vers laquelle le navigateur doit revenir.

POSThttps://api.wajub.com/payments
curl https://api.wajub.com/payments \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
  -H "Idempotency-Key: order-4172" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 5000,
    "currency": "XAF",
    "customer": { "email": "amina@example.com" },
    "reference": "order-4172",
    "callback": "https://shop.example.com/orders/4172/return"
  }'

L'en-tête Idempotency-Key empêche le double clic d'un client de créer deux paiements. Créez-le à partir d'une valeur stable, comme la référence de la commande, au lieu de laisser le SDK générer une nouvelle valeur aléatoire. Pendant vingt-quatre heures, la même clé renvoie le paiement initial. La même clé associée à un payload différent est refusée.

Enregistrez payment.id, puis redirigez le navigateur vers payment.authorization_url. Si votre processus s'arrête entre ces deux actions, une commande sans payment_id reste récupérable. Une commande payée impossible à relier à un paiement ne l'est pas.

3. Deux canaux vous annoncent le résultat

Après la redirection, le client paie. Deux canaux indépendants vous transmettent le résultat. Ils peuvent arriver dans n'importe quel ordre, seuls ou pas du tout.

CanalMoment de réceptionFiable à lui seul
Retour du navigateur vers votre callbackLe client revient sur votre siteNon
Webhook payment.succeededToujours, avec ou sans navigateurOui, après vérification de la signature

Le retour du navigateur ne constitue pas une preuve. N'importe qui peut ouvrir cette URL manuellement. Un client qui paie puis ferme l'onglet ne l'ouvre jamais. Le webhook se déclenche toujours, y compris lorsque la confirmation de l'opérateur arrive dix minutes plus tard.

Aucun des deux ne contrôle donc la livraison. Ils appellent tous deux la même fonction, qui prend la décision.

4. Écrire une livraison unique et répétable

Tout le reste repose sur cette partie : une seule fonction qui accepte un identifiant de paiement et peut être appelée autant de fois que nécessaire sans risque.

Trois éléments la rendent sûre. Elle récupère le paiement auprès de Wajub au lieu de faire confiance à l'appelant, vérifie le montant en plus du statut et réserve la commande avec une mise à jour conditionnelle avant tout traitement.

# The check the function makes, by hand. Fulfil on this and nothing else.
curl https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"

La réservation constitue la ligne importante. Il s'agit d'un seul UPDATE conditionnel qui réussit uniquement pour le premier appelant. Deux livraisons simultanées deviennent ainsi impossibles sans verrou.

Réserver une commande 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 effectuer la livraison. Zéro ligne signifie qu'un autre processus a déjà réservé la commande. La bonne réponse consiste alors à ne rien faire.

5. Le handler du webhook

En plus d'appeler la fonction que vous venez d'écrire, le handler doit prouver que la requête provient réellement de Wajub. La vérification utilise le corps brut de la requête, la signature et l'horodatage transmis dans les en-têtes.

En-têteContenu
X-Wajub-Signaturev1= suivi d'un HMAC-SHA256 de l'horodatage et du corps
X-Wajub-TimestampLa seconde Unix de signature de l'événement, refusée au-delà d'un écart de 300 secondes
X-Wajub-EventLe nom de l'événement, également présent dans le corps
X-Wajub-Delivery-IdValeur stable pendant les nouvelles tentatives de la même livraison, à utiliser pour la déduplication

Le SDK effectue toute la vérification, y compris la protection contre les répétitions. Transmettez-lui les octets intacts du corps, répondez 200, puis lancez le traitement.

# What Wajub sends you. Every SDK below verifies these three things.
POST /webhooks/wajub HTTP/1.1
X-Wajub-Signature: v1=8f3c…
X-Wajub-Timestamp: 1789012345
X-Wajub-Event: payment.succeeded
X-Wajub-Delivery-Id: whd_7Yh2MpL4tRb3nP8sZcXv
Content-Type: application/json

Répondez rapidement avec un code 200. 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. Les tâches lentes doivent être placées dans une file d'attente, pas exécutées dans la requête.

6. Le contenu réel de l'événement

Après vérification, l'événement est un objet simple avec une enveloppe fixe. event contient le nom et data l'objet concerné. Pour un paiement, cet objet est le paiement lui-même.

Un événement payment.succeeded vérifié
{
"id": "evt_9Lq5RtVb2Kd8",
"event": "payment.succeeded",
"livemode": true,
"api_version": "2026-08-01",
"created": "2026-09-13T10:31:02+00:00",
"data": {
"id": "trx_CSUGajfv9xh0XQ5wu2lx",
"reference": "order-4172",
"amount": 5000,
"amount_paid": 5000,
"currency": "XAF",
"status": "succeeded",
"channel": "cm.mtn",
"customer": {
"id": "cus_sAaim5apjocIgtlhzJY3wQ8s",
"email": "amina@example.com"
}
}
}

data.id correspond donc à l'identifiant du paiement, précisément la valeur acceptée par fulfillPayment. Deux événements sont importants pour une commande. Les autres doivent seulement être enregistrés dans les logs.

ÉvénementAction du worker
payment.succeededfulfillPayment(data.id)
payment.failedMarquer la commande comme échouée, enregistrer data.failure_reason et proposer une nouvelle tentative
payment.expiredMême action, la session a expiré avant la confirmation du client
payment.cancelledMême action, le client ou vous-même avez arrêté le paiement
payment.created, payment.processingLes enregistrer dans les logs. Ils aident le support, mais ne déclenchent rien

7. La page de retour

Le client revient vers votre callback. Cette page explique ce qui s'est passé. Elle doit afficher une information exacte plutôt qu'optimiste.

Wajub ajoute ses propres paramètres à cette URL. Leurs noms peuvent prêter à confusion : reference contient l'identifiant du paiement Wajub, trxref la référence envoyée et status la valeur communiquée au navigateur. Ne vous appuyez sur aucun d'eux pour une décision importante.

Interrogez plutôt l'API avec le payment_id enregistré sur la commande.

curl https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"

La branche de réussite appelle le même fulfillPayment. Le premier des deux canaux effectue le travail. Le second trouve une commande déjà réservée et se termine silencieusement. C'est tout l'intérêt d'une implémentation unique.

Un paiement pending n'est pas un échec. Le client n'a simplement pas terminé la confirmation sur son téléphone. Affichez une page qui l'explique et conservez la commande ouverte.

8. Quand aucun résultat n'arrive

Certaines commandes n'atteignent jamais un état final. Le client a ouvert la page puis l'a quittée, ou l'opérateur n'a plus répondu et la session a expiré sans être remarquée.

Vérifiez-les régulièrement. Pour toute commande encore awaiting_payment après une heure, effectuez une requête directe unique.

curl "https://api.wajub.com/payments?status=pending&per_page=100" \
  -H "Authorization: sk_test.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"

Cette tâche ne remplace pas les webhooks et ne devrait presque jamais trouver de résultat. Elle sert de filet de sécurité. Le jour où un endpoint de webhook est mal configuré, elle empêche le problème de passer inaperçu.

9. Tout tester dans la sandbox

Dans la sandbox, les six derniers chiffres du numéro de téléphone du client déterminent le résultat. Vous pouvez donc parcourir toutes les branches ci-dessus sans opérateur. Le préfixe sélectionne le pays et l'opérateur, tandis que le suffixe détermine le résultat.

SuffixeRésultatVérification dans votre code
000000RéussiteLa commande atteint fulfilled exactement une fois
000001Fonds insuffisantsLa commande atteint payment_failed et le client connaît la raison
000002Refus de l'opérateurMême parcours, autre raison
000003Délai de l'opérateur dépasséLa page d'attente reste affichée, puis la vérification résout la commande
000004Le client refuse l'inviteLa commande reste récupérable, elle n'est pas supprimée

Le préfixe de MTN Cameroun est +23767. Le numéro de test +237670000000 réussit donc, tandis que +237670000001 produit une erreur de fonds insuffisants. Tous les préfixes de pays et d'opérateurs figurent dans Scénarios de test.

Relancez un webhook au lieu de payer à nouveau

Après la réussite d'un paiement de sandbox, renvoyez ses événements depuis Konsole autant de fois que nécessaire. C'est le moyen le plus rapide de prouver l'idempotence réelle de votre handler, car le même événement est volontairement livré deux fois.

Que pensez-vous de ce contenu ?