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
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
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
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.
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é.
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.
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 |
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
/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 | 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. |
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
/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.
/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.
/payments/{id}/charge
Engage un paiement resté en attente. Champ customer_phone requis. Déclenche l'invite PIN du client.
/payments/{id}/cancel
Annule un paiement non tranché. Sans effet sur un paiement déjà définitif.
/account
Votre compte : statut, devise, soldes, tarifs appliqués, opérateurs disponibles dans ce mode.
/operators/detect?phone=…
Renvoie le numéro normalisé et l'opérateur détecté. Pratique pour afficher le bon logo avant de facturer.
/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.
/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.
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. Composez le *126# (MTN) ou le #150# (Orange) sur votre téléphone
- 2. Choisissez « Paiement marchand »
- 3. Entrez le code marchand affiché à l'écran
- 4. Entrez le montant exact de la commande
- 5. Validez avec votre code secret Mobile Money
Ce qui se passe ensuite
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.
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.
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
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';
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 :
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.