Votre boutique Laravel affiche un panier, le client clique « Payer » — et vous devez encaisser, en Côte d’Ivoire ou au Sénégal, aussi bien un paiement Orange Money qu’un Wave ou une carte. Intégrer chaque opérateur séparément vous prendrait des semaines. CinetPay fait l’inverse : une seule intégration, et la fenêtre de paiement propose tous les canaux. À la fin de ce tutoriel, votre application Laravel initiera un paiement multi-canal, redirigera le client vers CinetPay, et — surtout — confirmera l’encaissement côté serveur sans jamais se faire avoir par un faux retour.
Ce que vous allez apprendre
- Configurer les clés CinetPay proprement dans Laravel (env + config), sans jamais les exposer ;
- Initialiser un paiement via l’API
/v2/paymentet rediriger le client vers la page de paiement ; - Recevoir la notification serveur (webhook) et revérifier le statut réel via
/v2/payment/check; - Rendre l’encaissement idempotent pour ne jamais valider deux fois la même commande ;
- Tester tout le cycle en sandbox avant de toucher de l’argent réel.
Ce que vous allez construire
Le fil rouge : une route /commande/{order}/payer sur une boutique Laravel existante. Elle crée une transaction, envoie le client sur CinetPay, et une route webhook /cinetpay/notify marque la commande « payée » uniquement après vérification serveur. Résultat concret : une commande passe de « en attente » à « payée » sans intervention manuelle, et un faux appel au webhook ne déclenche rien.
Prérequis
- PHP 8.2+ et un projet Laravel 11 (au moment d’écrire) déjà initialisé, avec un modèle
Order. - Un compte CinetPay marchand : récupérez API Key, Site ID et Secret Key dans votre tableau de bord.
- Un domaine HTTPS accessible publiquement pour le webhook (en local, un tunnel type
ngrokouexpose). - ⏱️ Temps estimé : ~45 minutes. Test express : si vous savez créer un contrôleur et une migration Laravel, vous êtes prêt.
Étape 1 — Ranger les clés au bon endroit
Une clé d’API qui traîne dans le code, c’est une fuite en puissance. On les met dans .env (jamais commité) et on les expose à l’application via config/services.php. C’est aussi ce qui vous permettra d’avoir des clés différentes en test et en production sans toucher au code.
# .env
CINETPAY_API_KEY=votre_cle_api
CINETPAY_SITE_ID=votre_site_id
CINETPAY_SECRET_KEY=votre_cle_secrete
CINETPAY_BASE_URL=https://api-checkout.cinetpay.com/v2
// config/services.php — dans le tableau retourné
'cinetpay' => [
'key' => env('CINETPAY_API_KEY'),
'site_id' => env('CINETPAY_SITE_ID'),
'secret' => env('CINETPAY_SECRET_KEY'),
'base' => env('CINETPAY_BASE_URL', 'https://api-checkout.cinetpay.com/v2'),
],
Après un php artisan config:clear, ces valeurs sont accessibles partout via config('services.cinetpay.key'). Si config('services.cinetpay.site_id') renvoie null, c’est que le cache de config n’a pas été vidé — la cause n°1 de « ça marche en local, pas en prod ».
Étape 2 — Une table pour tracer chaque transaction
Avant d’appeler quoi que ce soit, on a besoin de garder une trace locale de chaque tentative de paiement : c’est elle qui rendra l’idempotence et la réconciliation possibles. Une transaction sans trace en base, c’est de l’argent qu’on ne saura jamais rattacher à une commande.
// database/migrations/xxxx_create_payments_table.php
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->string('transaction_id')->unique(); // notre référence, envoyée à CinetPay
$table->foreignId('order_id')->constrained();
$table->unsignedInteger('amount'); // en XOF, entier, multiple de 5
$table->string('status')->default('pending'); // pending | paid | failed
$table->timestamps();
});
Le unique() sur transaction_id n’est pas décoratif : c’est un garde-fou base de données contre le double traitement. Notez le amount en unsignedInteger : le franc CFA n’a pas de centimes, on ne manipule jamais le montant en flottant.
Étape 3 — Initialiser le paiement et rediriger
Voici le cœur de l’encaissement. On calcule le montant côté serveur (jamais depuis le formulaire), on crée la transaction locale, puis on appelle /v2/payment. CinetPay répond avec une payment_url : on y envoie le client. La règle d’or, c’est que amount vient de la base, pas de la requête.
// app/Http/Controllers/PaymentController.php
use IlluminateSupportFacadesHttp;
public function pay(Order $order)
{
$transactionId = 'CMD-'.$order->id.'-'.now()->timestamp;
Payment::create([
'transaction_id' => $transactionId,
'order_id' => $order->id,
'amount' => $order->total, // entier, multiple de 5, calculé en base
'status' => 'pending',
]);
$response = Http::asJson()->post(config('services.cinetpay.base').'/payment', [
'apikey' => config('services.cinetpay.key'),
'site_id' => config('services.cinetpay.site_id'),
'transaction_id' => $transactionId,
'amount' => $order->total,
'currency' => 'XOF',
'description' => 'Commande #'.$order->id,
'notify_url' => route('cinetpay.notify'),
'return_url' => route('cinetpay.return'),
'channels' => 'ALL', // ou 'MOBILE_MONEY' pour wallets seuls
'customer_name' => $order->customer_name,
'lang' => 'fr',
]);
$data = $response->json();
if (($data['code'] ?? null) === '201') {
return redirect()->away($data['data']['payment_url']);
}
return back()->withErrors('Paiement indisponible, réessayez dans un instant.');
}
Quelques points qui font la différence. channels => 'ALL' affiche mobile money et carte ; passez 'MOBILE_MONEY' si vous ne voulez que les wallets. Le code de succès de l’initialisation est la chaîne '201' (et non l’entier 201) : comparez bien des chaînes. Et amount doit être un multiple de 5, sinon CinetPay rejette la requête — arrondissez vos totaux en conséquence. Après redirect()->away(), le client est sur la page CinetPay : ce qui se passe ensuite, vous ne le contrôlez plus depuis le navigateur. D’où l’étape suivante.
Étape 4 — Le webhook : revérifier, ne jamais croire
Quand le paiement aboutit (ou échoue), CinetPay appelle votre notify_url. Erreur fatale du débutant : lire le statut dans le corps de cette requête et valider la commande. N’importe qui peut imiter cet appel. La parade est simple et imparable : on ignore le contenu du webhook, on en extrait juste l’identifiant de transaction, et on re-demande le vrai statut à CinetPay via /v2/payment/check.
// PaymentController.php
public function notify(Request $request)
{
$transactionId = $request->input('cpm_trans_id');
if (! $transactionId) {
return response('', 200);
}
$payment = Payment::where('transaction_id', $transactionId)->first();
if (! $payment) {
return response('', 200);
}
// Idempotence : déjà traité ? on s'arrête là.
if ($payment->status === 'paid') {
return response('', 200);
}
// Source de vérité : on revérifie côté serveur.
$check = Http::asJson()->post(config('services.cinetpay.base').'/payment/check', [
'apikey' => config('services.cinetpay.key'),
'site_id' => config('services.cinetpay.site_id'),
'transaction_id' => $transactionId,
])->json();
$accepted = ($check['code'] ?? null) === '00'
&& ($check['data']['status'] ?? null) === 'ACCEPTED';
if ($accepted) {
$payment->update(['status' => 'paid']);
$payment->order->markAsPaid(); // votre logique métier : email, stock, livraison
} else {
$payment->update(['status' => 'failed']);
}
return response('', 200);
}
Trois garde-fous cohabitent ici. Le first() sur notre table relie la notification à une transaction qu’on a bien créée. Le test status === 'paid' assure l’idempotence : si CinetPay rappelle deux fois (ça arrive), la deuxième passe sans rien refaire. Et le /v2/payment/check donne le statut authentique : code à '00' et data.status à 'ACCEPTED' signifient « payé pour de vrai ». On répond toujours 200 pour que CinetPay arrête de réémettre.
Pour une couche de défense supplémentaire, CinetPay joint à sa notification un en-tête x-token : un HMAC-SHA256 des données, calculé avec votre clé secrète. Vous pouvez le recalculer de votre côté et rejeter la requête s’il ne correspond pas, avant même d’appeler /v2/payment/check. Ce n’est pas un substitut à la re-vérification serveur — c’est une barrière de plus qui élimine d’emblée les appels grossièrement forgés.
Un raffinement pour les boutiques à fort trafic : enveloppez la mise à jour du paiement et la validation de la commande dans une transaction base de données (DB::transaction()) et verrouillez la ligne (lockForUpdate()) le temps du traitement. Deux webhooks qui arriveraient exactement en même temps ne pourront pas valider deux fois : le verrou sérialise leur passage.
Étape 5 — Exempter le webhook du CSRF
Laravel protège vos routes POST par un jeton CSRF. Un appel externe comme celui de CinetPay n’a pas ce jeton : sans exception, votre webhook renverrait une erreur 419 et vous perdriez toutes les notifications. On déclare donc les routes, en exemptant la route notify.
// routes/web.php
use AppHttpControllersPaymentController;
Route::get('/commande/{order}/payer', [PaymentController::class, 'pay'])->name('cinetpay.pay');
Route::get('/cinetpay/retour', [PaymentController::class, 'return'])->name('cinetpay.return');
Route::post('/cinetpay/notify', [PaymentController::class, 'notify'])->name('cinetpay.notify');
Pour l’exemption CSRF sous Laravel 11, ajoutez la route dans bootstrap/app.php, via $middleware->validateCsrfTokens(except: ['cinetpay/notify']). Sur Laravel 10, c’est la propriété $except de VerifyCsrfToken. Sans cette ligne, tout le reste fonctionne en apparence… mais aucune commande ne passera jamais « payée », et c’est un piège qui fait perdre des heures.
Étape 5 bis — Afficher le résultat au client
Le client revient sur votre return_url après avoir payé (ou abandonné). Cette page ne décide rien : elle montre où en est la commande d’après votre base, qui aura été mise à jour par le webhook. Tant que le webhook n’a pas confirmé, on affiche « vérification en cours » plutôt qu’un faux « merci pour votre achat ».
// PaymentController.php
public function return(Request $request)
{
// On NE valide rien ici : on lit l'état déjà confirmé par le webhook.
$payment = Payment::where('order_id', $request->query('order'))->latest()->first();
return view('paiement.retour', [
'paye' => $payment && $payment->status === 'paid',
'enCours' => $payment && $payment->status === 'pending',
]);
}
Ce découplage est volontaire : la page de retour est pour l’humain, le webhook pour la machine. Si vous validiez la commande ici, un client malin pourrait rejouer l’URL de retour sans avoir payé. La page de retour informe ; elle n’encaisse jamais.
Étape 6 — Vérifier de bout en bout en sandbox
On ne teste jamais un paiement directement avec de l’argent réel. Utilisez les identifiants sandbox de CinetPay et leurs numéros de test. Lancez une commande, payez avec un numéro de test, et observez : la redirection vers CinetPay, puis — quelques secondes plus tard — votre commande qui bascule en « payée » grâce au webhook. Pour voir le webhook arriver en local, exposez votre app avec un tunnel et mettez son URL HTTPS comme notify_url.
✅ Point d’étape — Vous devez voir, dans l’ordre : une ligne
pendingdans la tablepaymentsdès le clic « Payer », puis la même ligne enpaidaprès validation. Si elle restepending, le webhook n’arrive pas : vérifiez quenotify_urlest publique, en HTTPS, et exemptée du CSRF.
🐞 Pièges fréquents
| Symptôme | Cause probable | Correctif |
|---|---|---|
Réponse MINIMUM_REQUIRED_FIELDS ou rejet à l’init |
Champ manquant ou amount non multiple de 5 |
Vérifier tous les champs requis ; arrondir le montant à un multiple de 5 |
| Webhook en 419 | CSRF non exempté sur notify |
Exempter cinetpay/notify dans bootstrap/app.php |
| Commande jamais « payée » | notify_url non publique (localhost) |
Exposer via tunnel HTTPS ; tester l’URL au navigateur |
| Double livraison | Webhook reçu deux fois sans idempotence | Tester status === 'paid' avant d’agir |
config('services.cinetpay.key') à null |
Cache de config non vidé | php artisan config:clear |
🌍 Adaptation au contexte ouest-africain
CinetPay est ivoirien et couvre nativement les wallets de la zone (Orange Money, MTN, Moov, Wave selon les pays), ce qui en fait un choix solide pour encaisser large sans multiplier les contrats. Pensez « entier » pour le montant : le XOF n’a pas de décimale, et l’exigence du multiple de 5 vous évite déjà les arrondis hasardeux. Côté connectivité, les webhooks peuvent arriver en retard sur un réseau instable : si une commande reste pending trop longtemps, ajoutez un job planifié qui rappelle /v2/payment/check pour les transactions des dernières heures — votre filet de rattrapage. Pour héberger le endpoint de webhook, un petit VPS en HTTPS suffit amplement.
En sandbox, CinetPay fournit des numéros de mobile money fictifs pour simuler un paiement accepté ou refusé : testez les deux chemins, pas seulement le cas heureux. Un encaissement qui ne gère pas proprement le refus (solde insuffisant, abandon) frustre l’utilisateur et fausse vos statistiques de conversion.
✅ Récapitulatif
Vous avez branché un encaissement multi-canal complet : clés isolées, table de transactions, initialisation via /v2/payment avec redirection, et surtout un webhook qui ne fait confiance à personne — il revérifie chaque paiement via /v2/payment/check et reste idempotent. C’est exactement l’architecture décrite dans le pilier, appliquée à Laravel : le navigateur ne décide jamais ; le serveur, lui, vérifie.
🧾 Aide-mémoire
| Élément | Rôle |
|---|---|
POST /v2/payment |
Initialiser un paiement → renvoie data.payment_url (succès : code = '201') |
POST /v2/payment/check |
Vérifier le statut réel (payé : code = '00' et status = 'ACCEPTED') |
channels |
ALL (wallets + carte) ou MOBILE_MONEY |
amount |
Entier XOF, multiple de 5 |
notify_url |
Webhook serveur (exempté CSRF) |
💪 À vous de jouer
Ajoutez un job planifié php artisan make:job ReconcilePendingPayments qui, toutes les 10 minutes, rappelle /v2/payment/check pour chaque paiement resté pending depuis plus de 5 minutes, et met à jour son statut. C’est votre rattrapage anti-webhook-perdu.
Voir une piste
Dans handle(), faites Payment::where('status','pending')->where('created_at','<',now()->subMinutes(5))->get(), bouclez, et réutilisez exactement la logique de vérification du webhook (extraite dans une méthode partagée pour ne pas la dupliquer).
Tutoriels frères
- PayDunya en Django — le même flux d’encaissement, côté Python.
- Mobile money sur un site e-commerce — panier, commande et statut de bout en bout.
Pour aller plus loin
- 🔝 Retour au pilier : Mobile money en backend
- Documentation officielle CinetPay : docs.cinetpay.com
- SDK PHP officiel : github.com/cinetpay/cinetpay-php-sdk
- Documentation HTTP Client Laravel : laravel.com/docs/http-client
FAQ
Faut-il le SDK PHP CinetPay ou l’appel HTTP direct ?
Les deux marchent. L’appel HTTP via le client de Laravel (montré ici) ne dépend d’aucune version de SDK et reste lisible. Le SDK officiel cinetpay/cinetpay-php-sdk encapsule les mêmes endpoints si vous préférez.
Pourquoi revérifier alors que le webhook donne déjà le statut ?
Parce que le webhook est une requête HTTP imitable. Seul un appel serveur-à-serveur vers /v2/payment/check, authentifié par votre clé, fait foi.
Le client ferme l’onglet après avoir payé : la commande est-elle perdue ?
Non. Le webhook arrive indépendamment du navigateur, et votre job de réconciliation rattrape les cas où il manquerait.
Mots-clés : CinetPay Laravel, paiement multi-canal Afrique, API CinetPay, webhook paiement Laravel, mobile money Laravel, /v2/payment.
Aucun commentaire pour l'instant — lancez la discussion !