WalleoPay WalleoPay
FR
Mon compte
Se connecter Ouvrir un compte Mot de passe oublié Documentation

Ouverture gratuite. Vos clés de test sont disponibles immédiatement.

Documentation développeur

Encaissez du Mobile Money depuis votre code.

Une API REST, des montants en entiers, des webhooks signés, et une page de paiement que vos clients savent déjà utiliser. Tout ce qui suit fonctionne dès votre inscription, avec des clés de test et sans contrat opérateur.

https://walleopay.com/api/v1 Obtenir mes clés

Démarrage

Trois minutes suffisent pour encaisser votre premier paiement de test.

  1. 1

    Créez votre compte

    L'inscription est immédiate et ne demande aucun document. Votre compte démarre en attente de validation : le mode test est ouvert tout de suite, le mode réel après vérification de votre activité.

  2. 2

    Récupérez vos clés

    Tableau de bord → Clés API. Le secret n'est affiché qu'une fois, à sa création : nous n'en gardons qu'une empreinte. Perdu, il se renouvelle ; il ne se retrouve pas.

  3. 3

    Appelez l'API en test

    Créez un paiement, ouvrez son checkout_url, payez avec un numéro de test. Rien ne part vers un opérateur, aucun franc ne bouge.

sk_test_…

Tout est simulé : pas d'appel opérateur, pas d'argent, pas de commission. Les paiements se dénouent en 6 secondes selon le numéro utilisé.

sk_live_…

Vrais clients, vrais francs, vraie commission. Réservée aux comptes validés : une clé live sur un compte en attente rend 403 merchant_not_active.

Le mode découle de la clé, jamais d'un paramètre. Il n'existe aucun champ mode ou test à envoyer : changer de mode, c'est changer de clé. Un paiement créé avec une clé de test reste invisible pour une clé réelle, et réciproquement.

Services et clés

Un compte marchand peut porter plusieurs activités : une boutique en ligne, une application mobile, un site de contenu. Chacune est un service, déclaré et approuvé séparément, avec ses propres clés et ses propres URL de retour et de notification.

Catégorie Exemple
ecommerce Une boutique qui vend des articles physiques
application Une application mobile avec des achats intégrés
contenu Abonnement à un site, vidéos, formations
service Prestation facturée à l'acte
facture Facturation récurrente, abonnements
autre Tout ce qui ne rentre dans aucune case
La clé utilisée détermine le service. Vous n'envoyez jamais d'identifiant de service dans vos requêtes : chaque clé est rattachée à un service (ou au compte lui-même), et les paiements créés avec elle sont rangés sous ce service. Une boutique et une application qui partagent un compte gardent ainsi des statistiques, des webhooks et des révocations séparés. Pour encaisser au nom d'une nouvelle activité : déclarez le service, attendez son approbation, utilisez ses clés.

Authentification

Toutes les requêtes passent par HTTPS et portent votre clé secrète. La base de l'API :

https://walleopay.com/api/v1

Envoyez le secret dans l'en-tête Authorization :

Authorization: Bearer sk_test_votre_cle

Si votre hébergement mange l'en-tête Authorization — cela arrive encore sur du mutualisé Apache mal configuré — utilisez l'en-tête de repli :

X-WalleoPay-Secret: sk_test_votre_cle
Le secret reste sur votre serveur. Jamais dans du JavaScript de navigateur, jamais dans une application mobile, jamais dans un dépôt public. Quiconque le détient peut créer des paiements en votre nom et lire vos encaissements. Une clé exposée se révoque depuis le tableau de bord, et la révocation est immédiate.
POST /api/v1/payments

Créer un paiement

Un paiement naît « en attente » : rien n'est encore envoyé à un opérateur. À vous de choisir ensuite comment le client paie.

Champs

Champ Type   Règle
amount entier requis Montant en unités mineures. Le XAF n'a pas de centimes : 12500 = 12 500 FCFA. Entre 100 FCFA et 1 000 000 FCFA.
currency chaîne (3) optionnel Devise ISO. Par défaut celle de votre compte (XAF).
description chaîne optionnel 255 caractères. Affiché au client sur la page de paiement.
reference chaîne optionnel 120 caractères. Votre identifiant de commande, unique chez vous : une deuxième création avec la même référence est refusée en 422. Sert ensuite à relire le paiement.
customer_name chaîne optionnel 120 caractères.
customer_email e-mail optionnel 180 caractères, adresse valide.
customer_phone chaîne optionnel Numéro camerounais sous n'importe quelle forme : 691234567, 0691234567, 237691234567, +237 691 23 45 67. Normalisé en E.164, l'opérateur est déduit du préfixe. Un numéro illisible rend 422.
return_url URL optionnel Où renvoyer le client après le paiement. Par défaut l'URL de retour de votre compte.
cancel_url URL optionnel Où renvoyer le client s'il abandonne.
notify_url URL optionnel Destination des webhooks pour ce paiement. Par défaut l'URL de notification de votre compte. Sans elle, aucune notification n'est envoyée.
metadata objet optionnel Ce que vous voulez y ranger. Renvoyé tel quel dans les réponses et les webhooks.
charge booléen optionnel À true avec un customer_phone valide, déclenche l'encaissement immédiatement au lieu d'attendre la page de paiement.
Le franc CFA n'a pas de centimes. Contrairement à l'euro ou au dollar, son exposant décimal est zéro : 12 500 FCFA s'envoie 12500, pas 1250000. Si vous portez du code écrit pour Stripe, c'est la première chose à corriger — et elle coûte cher dans les deux sens.

Requête

curl -X POST https://walleopay.com/api/v1/payments \
  -H "Authorization: Bearer sk_test_votre_cle" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: commande-1042" \
  -d '{
    "amount": 12500,
    "reference": "A1042",
    "description": "Commande A1042",
    "notify_url": "https://ma-boutique.cm/walleopay/webhook",
    "return_url": "https://ma-boutique.cm/merci"
  }'

Réponse 201 Created

{
    "data": {
        "id": "pay_01k5m3q9zt6r8v2wxyd4n7hpab",
        "object": "payment",
        "mode": "test",
        "status": "pending",
        "amount": 12500,
        "currency": "XAF",
        "fee": 375,
        "net": 12125,
        "description": "Commande A1042",
        "reference": "A1042",
        "operator": null,
        "customer": {
            "name": "Awa Nguema",
            "email": "awa@example.cm",
            "phone": null
        },
        "checkout_url": "https://walleopay.com/pay/8f3c1d0a5b9e4726ac18d5f0b3e71249c6a8047d",
        "failure_code": null,
        "failure_message": null,
        "metadata": {
            "commande_id": 1042
        },
        "paid_at": null,
        "expires_at": "2026-09-20T16:39:56+01:00",
        "created_at": "2026-09-20T16:09:56+01:00"
    }
}

fee est notre commission, calculée à la création et prélevée seulement si le paiement aboutit. net est ce qui atterrira sur votre solde. Les horodatages sont en ISO 8601, avec leur décalage.

Deux façons de faire payer

Rediriger vers checkout_url

La solution par défaut, et la plus sûre. Nous affichons la page de paiement, collectons le numéro, reconnaissons l'opérateur, gérons l'attente et les nouvelles tentatives. Vous n'avez rien à construire et vous ne touchez jamais au numéro de votre client.

Passer charge: true

Si vous collectez déjà le numéro dans votre propre interface, ajoutez customer_phone et charge: true : l'invite PIN part immédiatement, sans page intermédiaire. La réponse revient en processing — à vous de gérer l'écran d'attente.

curl -X POST https://walleopay.com/api/v1/payments \
  -H "Authorization: Bearer sk_test_votre_cle" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 12500,
    "customer_phone": "699000001",
    "charge": true
  }'

Un paiement déjà créé se déclenche aussi plus tard, avec POST /payments/{id}/charge. Relancer un paiement déjà engagé ne renvoie pas une seconde invite PIN : le client n'aura jamais deux demandes pour une commande.

L'en-tête Idempotency-Key

Le réseau coupe entre votre serveur et le nôtre. Vous ne savez pas si le paiement a été créé. Vous réessayez — et vous risquez de facturer deux fois la même commande. L'en-tête Idempotency-Key supprime ce risque : envoyez-le sur POST /payments et POST /payouts, avec une valeur stable dérivée de votre commande.

POST /api/v1/payments
Authorization: Bearer sk_test_votre_cle
Idempotency-Key: commande-1042
Content-Type: application/json

→ 201  { "data": { "id": "pay_01k5m3…", "status": "pending", … } }

# Le réseau a coupé, vous renvoyez exactement le même corps :

→ 201  Idempotent-Replay: true
       { "data": { "id": "pay_01k5m3…", … } }   ← le même paiement

# Même clé, corps différent :

→ 409  { "error": { "type": "idempotency_conflict", "message": "…" } }

Rejeu identique

Même clé, même corps : nous renvoyons la réponse d'origine, code HTTP compris, avec l'en-tête Idempotent-Replay: true. Aucun second paiement n'est créé. Les refus sont mémorisés aussi : rejouer une requête refusée en 422 rend la même 422.

Corps différent

Même clé, corps différent : 409 idempotency_conflict. Une clé d'idempotence appartient à une requête précise ; ne la recyclez pas d'une commande à l'autre.

Première requête encore en cours

La requête d'origine n'a pas fini : 409 idempotency_in_progress. Attendez quelques secondes et réessayez ; ne créez surtout pas un doublon avec une autre clé.

Requête morte en vol

Si la requête d'origine s'interrompt sans réponse, le verrou se relâche au bout de 90 secondes et votre réessai reprend la main normalement. Et si notre serveur a rendu une erreur 5xx, la clé est effacée : elle redevient utilisable tout de suite.

Une clé est propre à votre compte et au point d'entrée appelé : la même valeur chez deux marchands, ou sur deux points d'entrée différents, ne se marche pas dessus. Un bon choix de clé : commande-1042, facture-2026-0117 — quelque chose que votre code recalculera à l'identique au réessai.

Les autres points d'entrée

GET /payments

Liste paginée, du plus récent au plus ancien. Filtres status et reference, per_page jusqu'à 100. Ne renvoie que les paiements du mode de la clé utilisée.

GET /payments/{id}

Accepte l'identifiant WalleoPay (pay_…) ou votre propre référence. Si le paiement n'est pas tranché, son état est redemandé à l'opérateur avant de répondre. C'est le point d'entrée à interroger avant de livrer.

POST /payments/{id}/charge

Engage un paiement resté en attente. Champ customer_phone requis. Déclenche l'invite PIN du client.

POST /payments/{id}/cancel

Annule un paiement non tranché. Sans effet sur un paiement déjà définitif.

GET /account

Votre compte : statut, devise, soldes, tarifs appliqués, opérateurs disponibles dans ce mode.

GET /operators/detect?phone=…

Renvoie le numéro normalisé et l'opérateur détecté. Pratique pour afficher le bon logo avant de facturer.

POST /payouts

Demande un reversement vers un numéro Mobile Money. Le solde est débité du montant plus les frais au moment de la demande.

GET /payouts/{id}

État d'un reversement, par identifiant ou par votre référence.

GET /payments/{id} est votre source de vérité. Il accepte indifféremment pay_01k5m3… ou votre propre reference, et il redemande l'état à l'opérateur avant de répondre quand rien n'est encore tranché. C'est lui qu'on interroge avant de livrer une commande — pas le webhook, pas l'URL de retour.

Statuts d'un paiement

Un statut définitif ne changera plus jamais : vous pouvez classer la commande et cesser d'interroger. Les autres bougeront encore.

Valeur Libellé Définitif Ce que ça veut dire
pending En attente non Créé, le client n'a encore rien fait. C'est l'état qui sort de POST /payments.
processing En cours non Demande transmise à l'opérateur : le client a l'invite PIN sur son téléphone.
awaiting_confirmation À confirmer non Paiement au code marchand USSD. Le client dit avoir payé, mais aucun opérateur ne peut le confirmer : un humain doit rapprocher. Ne livrez rien.
succeeded Réussi oui Encaissé. Votre solde est crédité du net. C'est le seul état qui autorise la livraison.
failed Échoué oui Refusé : solde insuffisant, code erroné, client qui décline. failure_code en dit la raison.
cancelled Annulé oui Annulé par le client sur la page de paiement, ou par vous via l'API.
expired Expiré oui Délai dépassé sans dénouement (30 minutes par défaut).
succeeded est le seul statut qui autorise à livrer. awaiting_confirmation signifie « le client affirme avoir payé, personne ne l'a encore vérifié » : traitez-le comme une commande en attente, pas comme une vente.

Mode code marchand

MTN et Orange délivrent un code marchand bien avant d'ouvrir l'accès à leur API. Ce mode permet d'encaisser pour de vrai dès aujourd'hui, en attendant la signature du contrat technique — au prix d'une vérification humaine, que nous préférons annoncer plutôt que déguiser.

Aucune confirmation automatique n'est possible dans ce mode. L'opérateur ne nous notifie de rien : pas de callback, pas de point d'interrogation de statut. Interroger GET /payments/{id} en boucle ne fera pas basculer l'état tout seul. Le paiement devient succeeded quand un humain a rapproché la transaction avec le relevé MoMo, et pas avant.

Ce que voit votre client

À la place du formulaire habituel, la page de paiement affiche le code marchand et cinq instructions :

  1. 1. Composez le *126# (MTN) ou le #150# (Orange) sur votre téléphone
  2. 2. Choisissez « Paiement marchand »
  3. 3. Entrez le code marchand affiché à l'écran
  4. 4. Entrez le montant exact de la commande
  5. 5. Validez avec votre code secret Mobile Money

Ce qui se passe ensuite

1

Le paiement passe en awaiting_confirmation

Un webhook payment.awaiting_confirmation part vers votre notify_url. Le délai d'expiration est allongé à 48 heures : un rapprochement manuel prend parfois une journée ouvrable.

2

Le client recopie l'identifiant reçu par SMS

Son opérateur lui envoie un identifiant de transaction. La page de paiement lui demande de le saisir : c'est ce numéro qui permet de retrouver le versement sur le relevé. Vous le retrouvez dans metadata.customer_transaction_id.

3

Un humain confirme, ou refuse

Après rapprochement, le paiement devient succeeded — votre solde est crédité et le webhook payment.succeeded part comme pour n'importe quel encaissement. En cas de versement introuvable, il devient failed avec le code confirmation_rejected.

Côté intégration, rien ne change : vous créez le paiement exactement de la même façon et vous redirigez vers checkout_url. Seule différence à prévoir dans votre code : le statut awaiting_confirmation, qu'il faut traiter comme « en attente » et non comme un échec.

Les reversements, eux, ne se pilotent pas dans ce mode : sans API opérateur, il n'y a rien à déclencher. POST /payouts accepte bien la demande et débite votre solde, mais l'envoi échoue ensuite avec le code merchant_code_disbursement_manual et votre solde est recrédité intégralement. Le virement se fait à la main depuis l'application MoMo. Surveillez donc l'état du reversement, pas seulement le 201 de sa création.

Migrer du code marchand vers l'API MoMo

Le jour où le contrat technique MTN est signé, la bascule est une ligne de configuration chez nous : config('walleopay.routing.live.mtn_momo') passe de merchant_code à mtn_momo. Vous n'avez rien à modifier.

Ce qui ne bouge pas

  • Vos clés API, les mêmes
  • Les points d'entrée et le format des requêtes
  • Le format des réponses et des webhooks
  • La signature des webhooks et votre code de vérification
  • Vos références de commande et votre historique

Ce qui change pour le client

  • Plus de code USSD à composer : l'invite PIN arrive sur son téléphone
  • Confirmation en quelques secondes au lieu de quelques heures
  • Le statut awaiting_confirmation disparaît du cycle de vie, remplacé par processing
Écrivez dès maintenant un code qui gère les deux. Un switch sur le statut qui traite pending, processing et awaiting_confirmation comme « pas encore payé », et succeeded comme le seul feu vert, survivra à la bascule sans une ligne de changement. Pendant la transition, les deux modes peuvent d'ailleurs coexister : MTN par code marchand, Orange par API, ou l'inverse.

Webhooks

Dès qu'un paiement ou un reversement change d'état, nous appelons votre notify_url en POST, avec un corps JSON signé.

Événements émis

payment.succeeded Le paiement est encaissé, votre solde est crédité.
payment.awaiting_confirmation Paiement au code marchand : le client a reçu les instructions USSD, la vérification humaine reste à faire.
payment.failed Le paiement a été refusé.
payment.expired Le délai est passé sans dénouement.
payment.cancelled Le client ou vous avez annulé.
payout.paid Le reversement est arrivé sur le numéro du bénéficiaire.
payout.failed L'envoi a échoué : votre solde a été recrédité intégralement.
payout.rejected Le reversement a été refusé côté WalleoPay : votre solde a été recrédité.

En-têtes envoyés

X-WalleoPay-Signature t=<horodatage>,v1=<hmac sha256>
X-WalleoPay-Event Le nom de l'événement, par exemple payment.succeeded
X-WalleoPay-Delivery Identifiant unique de cette livraison (evt_…). Le même à chaque réessai : servez-vous-en pour dédoublonner.

Format de la signature

La signature est un HMAC-SHA256 calculé sur la chaîne horodatage . "." . corps, avec le secret de webhook de votre compte. L'horodatage est inclus dans la chaîne signée : un appel capté ne peut donc pas être rejoué plus tard avec une date fraîche.

X-WalleoPay-Signature: t=1758300000,v1=5f2e91c4a7...c91a

hmac_sha256("1758300000." + corps_brut, whsec_votre_secret)

Vérification côté marchand (PHP)

<?php
// POST https://ma-boutique.cm/walleopay/webhook

$secret = 'whsec_votre_secret';   // Tableau de bord > Webhooks
$body   = file_get_contents('php://input');
$entete = $_SERVER['HTTP_X_WALLEOPAY_SIGNATURE'] ?? '';

// En-tête reçu : « t=1758300000,v1=5f2e...c91a »
$parties = [];
foreach (explode(',', $entete) as $morceau) {
    [$cle, $valeur] = array_pad(explode('=', $morceau, 2), 2, '');
    $parties[$cle] = $valeur;
}

$horodatage = (int) ($parties['t'] ?? 0);
$recue      = (string) ($parties['v1'] ?? '');

// 1. Fenêtre temporelle : sans elle, un appel capté aujourd'hui
//    pourrait être rejoué dans six mois, signature valide à l'appui.
if ($horodatage <= 0 || abs(time() - $horodatage) > 300) {
    http_response_code(400);
    exit('horodatage hors tolerance');
}

// 2. La signature porte sur « horodatage.corps », avec le corps brut :
//    ne le décodez pas avant de l'avoir vérifié.
$attendue = hash_hmac('sha256', $horodatage.'.'.$body, $secret);

// hash_equals, jamais == : la comparaison doit être à temps constant.
if (! hash_equals($attendue, $recue)) {
    http_response_code(400);
    exit('signature invalide');
}

$evenement = json_decode($body, true);

// 3. Le webhook vous dit d'aller voir ; il ne vous dit pas de livrer.
//    On redemande l'état à la source avant de toucher à la commande.
$paiement = walleopay_get('/payments/'.$evenement['data']['id']);

if ($paiement['data']['status'] === 'succeeded') {
    livrer_commande($paiement['data']['reference'], $paiement['data']['amount']);
}

// Répondez 2xx, sinon nous réessaierons.
http_response_code(200);
echo 'ok';
Ne livrez jamais une commande sur la seule foi du webhook. Une signature valide prouve que l'appel vient de nous, pas que l'argent est arrivé : un webhook peut être rejoué, arriver dans le désordre, ou concerner un paiement qui a changé d'état depuis. Traitez-le comme un signal « allez vérifier », et rappelez GET /payments/{id} avant de déclencher la livraison. C'est deux lignes de code, et c'est ce qui sépare une intégration solide d'une boutique qu'on vide à distance.

Politique de réessai

Une réponse 2xx vaut accusé de réception. Tout le reste — code d'erreur, délai dépassé, serveur injoignable — déclenche un réessai, jusqu'à 8 tentatives, espacées ainsi :

10 s 30 s 2 min 10 min 30 min 2 h 6 h

Chaque appel a 10 secondes pour répondre : répondez vite et traitez ensuite, plutôt que de faire votre travail avant de répondre. Vos livraisons sont consultables et rejouables à la main depuis le tableau de bord. Tolérez les doublons : un même événement peut arriver deux fois, et votre traitement doit rester sans effet la seconde fois.

Mode test

Avec une clé sk_test_…, rien ne sort de nos serveurs. Le sort d'un paiement est décidé par le numéro utilisé, ce qui vous permet de dérouler les chemins d'échec sans attendre un vrai refus d'opérateur. Comptez 6 secondes de délai simulé avant le dénouement : c'est fait exprès, pour que vous voyiez l'écran d'attente que verra votre client.

Numéro Opérateur Résultat Ce que vous recevez
699000001 Orange Money succeeded Paiement encaissé, solde de test crédité du net.
699000002 Orange Money failed insufficient_funds Solde insuffisant sur le compte du client.
699000003 Orange Money failed payer_rejected Le client a refusé la demande de paiement.
699000004 Orange Money failed timeout Le client n'a pas saisi son code à temps.
670000001 MTN MoMo succeeded Paiement encaissé, solde de test crédité du net.
670000002 MTN MoMo failed insufficient_funds Solde insuffisant sur le compte du client.

Tout autre numéro camerounais valide aboutit : c'est le cas que vous voulez voir passer quand vous branchez votre boutique. Les paiements de test alimentent un solde de test, séparé de votre solde réel, et n'apparaissent jamais dans vos chiffres de production.

Codes d'erreur

Un refus métier arrive dans cette enveloppe :

{
    "error": {
        "type": "merchant_not_active",
        "message": "Votre compte n'est pas encore activé pour les paiements réels…"
    }
}

Un refus de validation, lui, arrive au format standard de Laravel, sans error.type. Prévoyez les deux formes :

{
    "message": "Un paiement existe déjà avec cette référence.",
    "errors": {
        "reference": ["Un paiement existe déjà avec cette référence."]
    }
}
type HTTP Quand
authentication_error 401 Clé absente, inconnue ou révoquée.
merchant_not_active 403 Clé live sur un compte qui n'est pas encore validé. Restez en test le temps de la validation.
invalid_request 422 Paramètre refusé hors validation de formulaire : numéro de téléphone illisible, par exemple.
not_found 404 Aucun paiement ou reversement ne correspond, ou il appartient à un autre marchand ou à l'autre mode.
idempotency_conflict 409 Cette clé d'idempotence a déjà servi pour un corps différent.
idempotency_in_progress 409 La requête d'origine tourne encore. Réessayez dans quelques secondes.
invalid_msisdn 422 Numéro non normalisable en numéro camerounais.
operator_unknown 422 Le préfixe ne correspond ni à MTN ni à Orange.
operator_unavailable 422 Aucun driver n'est configuré pour cet opérateur dans ce mode.
operator_unsupported 422 Le driver configuré pour cet opérateur ne peut pas le traiter — typiquement, mode code marchand sans code configuré pour cet opérateur.
payout_refused 422 Solde insuffisant, ou montant sous le minimum de 1 000 FCFA.

Quand le refus concerne un paiement déjà créé, la réponse d'erreur porte en plus une clé data avec l'état du paiement : vous savez ce qui existe de votre côté sans avoir à le relire.

Limites

120

requêtes par minute et par clé

La limite est posée sur la clé, pas sur l'adresse : plusieurs marchands partagent souvent la même sortie internet.

300

requêtes par minute et par adresse IP

Plafond global, toutes clés confondues, pour une même origine.

Au-delà, l'API répond 429 Too Many Requests avec un en-tête Retry-After. Respectez-le : réessayer en boucle ne fera que prolonger le blocage. Si vous sondez l'état d'un paiement, espacez vos appels de quelques secondes plutôt que de marteler — ou laissez le webhook vous prévenir.

Pas d'intégration en iframe

Nos pages portent une politique de sécurité de contenu stricte et un X-Frame-Options: SAMEORIGIN : la page de paiement ne s'affichera pas dans une iframe hébergée sur votre domaine, et c'est voulu — un formulaire de paiement encadré par un site tiers est exactement ce qu'un attaquant cherche à fabriquer. Redirigez vers checkout_url, puis récupérez le client sur votre return_url. Sans incidence pour vos appels serveur à serveur, qui ne sont pas concernés.

Horodatages

Les dates que l'API renvoie — created_at, paid_at, expires_at — sont au format ISO 8601 avec leur décalage, par exemple 2026-09-19T23:30:00+01:00 : la plateforme tourne sur Africa/Douala, qui ne change jamais d'heure. Analysez-les avec une bibliothèque de dates plutôt que de découper la chaîne, et comparez toujours des instants, jamais des textes. L'horodatage t= des webhooks, lui, est un horodatage Unix en secondes : il est par nature sans fuseau.

Montants et délais

Un paiement va de 100 FCFA à 1 000 000 FCFA. Une page de paiement expire au bout de 30 minutes (48 heures en mode code marchand). Un reversement part de 1 000 FCFA.

Exemples complets

PHP — créer et rediriger

<?php

$secret = 'sk_test_votre_cle';   // jamais dans le code du navigateur
$base   = 'https://walleopay.com/api/v1';

$payload = json_encode([
    'amount'         => 12500,          // 12 500 FCFA : le XAF n'a pas de centimes
    'currency'       => 'XAF',
    'reference'      => 'A1042',        // votre numéro de commande
    'description'    => 'Commande A1042',
    'customer_name'  => 'Awa Nguema',
    'customer_email' => 'awa@example.cm',
    'return_url'     => 'https://ma-boutique.cm/merci',
    'cancel_url'     => 'https://ma-boutique.cm/panier',
    'notify_url'     => 'https://ma-boutique.cm/walleopay/webhook',
    'metadata'       => ['commande_id' => 1042],
]);

$ch = curl_init($base.'/payments');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $payload,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 20,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer '.$secret,
        'Content-Type: application/json',
        'Accept: application/json',
        // Rejouer cet appel après une coupure réseau ne créera pas
        // un second paiement pour la même commande.
        'Idempotency-Key: commande-1042',
    ],
]);

$body   = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$reponse = json_decode($body, true);

if ($status !== 201) {
    // Refus métier : ['error' => ['type' => ..., 'message' => ...]]
    // Refus de validation : ['message' => ..., 'errors' => ['amount' => [...]]]
    throw new RuntimeException($reponse['error']['message'] ?? $reponse['message'] ?? 'Création refusée.');
}

// Rangez l'identifiant à côté de votre commande : c'est lui qui vous
// servira à vérifier le paiement plus tard.
enregistrer_paiement(1042, $reponse['data']['id']);

// Puis envoyez le client payer.
header('Location: '.$reponse['data']['checkout_url']);
exit;

JavaScript — créer et vérifier

// Node.js — le secret ne quitte jamais votre serveur.
const BASE = 'https://walleopay.com/api/v1';
const SECRET = process.env.WALLEOPAY_SECRET; // sk_test_… ou sk_live_…

async function creerPaiement(commande) {
  const reponse = await fetch(`${BASE}/payments`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${SECRET}`,
      'Content-Type': 'application/json',
      Accept: 'application/json',
      'Idempotency-Key': `commande-${commande.id}`,
    },
    body: JSON.stringify({
      amount: commande.total,              // entier, en FCFA
      reference: `CMD-${commande.id}`,
      description: `Commande ${commande.id}`,
      customer_phone: commande.telephone,  // 6XXXXXXXX ou +2376XXXXXXXX
      notify_url: 'https://ma-boutique.cm/walleopay/webhook',
      return_url: 'https://ma-boutique.cm/merci',
      metadata: { commande_id: commande.id },
    }),
  });

  const corps = await reponse.json();

  if (!reponse.ok) {
    // 429 : vous cognez trop vite, respectez l'en-tête Retry-After.
    throw new Error(corps.error?.message ?? corps.message ?? 'Création refusée');
  }

  return corps.data; // { id, status, checkout_url, … }
}

async function estPaye(paymentId) {
  const reponse = await fetch(`${BASE}/payments/${paymentId}`, {
    headers: { Authorization: `Bearer ${SECRET}`, Accept: 'application/json' },
  });

  const { data } = await reponse.json();

  // « succeeded » est le seul état qui autorise la livraison.
  // « awaiting_confirmation » veut dire « on ne sait pas encore ».
  return data.status === 'succeeded';
}

// Parcours complet : on crée, puis on envoie le client sur checkout_url.
const paiement = await creerPaiement({ id: 1042, total: 12500, telephone: '699000001' });
reponseHttp.redirect(paiement.checkout_url);

Prêt à encaisser ?

Vos clés de test vous attendent dès la création du compte.