ITSkillsCenter
Blog

CinetPay multi-canal en Laravel : encaisser sur tous les wallets pas-à-pas

16 min de lecture

📍 Guide principal de la série : Mobile money en backend 2026 — Wave, Orange Money, PayDunya, CinetPay

Introduction

CinetPay est l’agrégateur abidjanais qui couvre la zone Franc CFA dans son ensemble — Côte d’Ivoire, Sénégal, Burkina Faso, Mali, Cameroun, Togo, Bénin, Guinée Conakry, République Démocratique du Congo — avec une API unifiée pour mobile money, carte bancaire et wallets régionaux. Ce tutoriel construit l’intégration en Laravel 11 : Service Provider isolé, Job de retry pour la vérification de transaction, webhook avec contrôle d’intégrité, support du SDK seamless pour intégration sans redirection. La cible est un produit multi-pays qui doit accepter aussi bien les utilisateurs Wave et Orange Money que les paiements carte des clients étrangers.

Prérequis

  • PHP 8.3+ et Composer 2.7+
  • Laravel 11.x installé et fonctionnel
  • Un compte business CinetPay activé sur admin.cinetpay.com
  • L’apikey et le site_id récupérés depuis le tableau de bord CinetPay
  • Une base de données accessible (PostgreSQL recommandé en prod)
  • Un tunnel HTTPS pour le développement local
  • Niveau intermédiaire en Laravel (Service Provider, Jobs, Webhooks)
  • Temps estimé : 2 à 3 heures pour le code, 1 jour de validation sandbox

Étape 1 — Récupérer apikey et site_id depuis le tableau de bord CinetPay

L’inscription business CinetPay se fait sur cinetpay.com et exige le formulaire classique d’une activité commerciale : raison sociale, registre du commerce, NINEA pour le Sénégal ou son équivalent ailleurs, justificatif d’identité du représentant légal, RIB du compte de versement. La validation prend en général deux à cinq jours ouvrés.

Une fois le compte validé, connectez-vous à admin.cinetpay.com et créez un nouveau service. Le formulaire de création vous demande le nom du service, le pays cible, le canal de paiement par défaut, et les URLs de retour et de notification. À la création du service, CinetPay vous expose deux identifiants à noter : l’apikey (clé d’authentification) et le site_id (identifiant unique du service).

Particularité utile à connaître : ces deux identifiants sont les mêmes en sandbox et en production. La distinction entre les deux environnements se fait au niveau du compte tout entier — un compte business peut activer le sandbox depuis la page « Test » du tableau de bord, et chaque transaction lancée pendant cette période est marquée comme test sans être facturée. Cela simplifie le code parce qu’il n’y a pas de mode à toggler côté application.

Étape 2 — Créer la table cinetpay_transactions et le modèle Eloquent

La table d’audit côté Laravel suit la même philosophie que pour Wave Payout : un identifiant interne unique (transaction_id côté CinetPay correspond à votre client_reference, jamais réutilisé), le payment_token retourné après création, le statut courant, le canal effectivement utilisé par le client, et la dernière réponse pour audit complet.

Créez la migration :

php artisan make:migration create_cinetpay_transactions_table

Et complétez-la avec les colonnes nécessaires. CinetPay distingue plusieurs statuts dans ses réponses. La documentation officielle confirme ACCEPTED (code 00, succès), REFUSED (code 627, échec), et WAITING_FOR_CUSTOMER (en attente de validation utilisateur). On les normalise dans une énumération applicative et on traite tout statut intermédiaire comme « en attente » pour laisser le cron de réconciliation décider.

<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('cinetpay_transactions', function (Blueprint $table) {
            $table->id();
            $table->string('transaction_id', 32)->unique();
            $table->string('payment_token', 64)->nullable();
            $table->unsignedInteger('amount');
            $table->string('currency', 5)->default('XOF');
            $table->string('description', 255);
            $table->string('channels', 30)->default('ALL');
            $table->string('status', 20)->default('pending');
            $table->string('operator', 30)->nullable();
            $table->string('phone_number', 20)->nullable();
            $table->json('last_response')->nullable();
            $table->timestamp('confirmed_at')->nullable();
            $table->timestamps();
            $table->index(['status', 'created_at']);
        });
    }

    public function down(): void { Schema::dropIfExists('cinetpay_transactions'); }
};

Appliquez la migration avec php artisan migrate. La colonne operator se remplit après réponse de CinetPay et indique le moyen de paiement effectivement utilisé par le client (Wave Money, Orange Money, MTN MoMo selon le pays, ou Carte Visa/Mastercard) — utile pour la facturation aux utilisateurs et pour les statistiques de conversion par méthode.

Étape 3 — Implémenter le CinetPayService

Le service expose deux méthodes publiques : initialize() qui crée la transaction côté CinetPay et retourne l’URL de redirection, et check() qui interroge l’API pour récupérer l’état courant d’une transaction. La construction est volontairement minimale — toute logique métier (validation des montants, choix du canal selon la méthode demandée par le client) reste côté contrôleur.

<?php
namespace App\Services;

use App\Models\CinetPayTransaction;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class CinetPayService
{
    private string $apiKey;
    private string $siteId;
    private string $baseUrl = 'https://api-checkout.cinetpay.com/v2';

    public function __construct()
    {
        $this->apiKey = config('services.cinetpay.api_key');
        $this->siteId = config('services.cinetpay.site_id');
    }

    public function initialize(int $amount, string $description, string $channels = 'ALL', array $customer = []): CinetPayTransaction
    {
        if ($amount % 5 !== 0) {
            throw new \InvalidArgumentException('CinetPay : montant doit être multiple de 5');
        }
        $transactionId = (string) Str::ulid();
        $tx = CinetPayTransaction::create([
            'transaction_id' => $transactionId,
            'amount' => $amount,
            'description' => $description,
            'channels' => $channels,
            'status' => 'pending',
        ]);

        $payload = array_merge([
            'apikey' => $this->apiKey,
            'site_id' => $this->siteId,
            'transaction_id' => $transactionId,
            'amount' => $amount,
            'currency' => 'XOF',
            'description' => $description,
            'notify_url' => config('services.cinetpay.notify_url'),
            'return_url' => config('services.cinetpay.return_url'),
            'channels' => $channels,
        ], $customer);

        $resp = Http::asJson()->timeout(20)->post("{$this->baseUrl}/payment", $payload);
        $body = $resp->json();
        $tx->update(['last_response' => $body]);

        if (($body['code'] ?? null) !== '201') {
            $tx->update(['status' => 'failed']);
            throw new \RuntimeException('CinetPay init failed: '.($body['message'] ?? 'unknown'));
        }
        $tx->update([
            'payment_token' => $body['data']['payment_token'] ?? null,
        ]);
        return $tx->refresh();
    }

    public function check(string $transactionId): array
    {
        $resp = Http::asJson()->timeout(15)->post("{$this->baseUrl}/payment/check", [
            'apikey' => $this->apiKey,
            'site_id' => $this->siteId,
            'transaction_id' => $transactionId,
        ]);
        return $resp->json() ?? [];
    }
}

Trois choix de design valent l’explication. La validation $amount % 5 !== 0 est portée par le service parce que CinetPay rejette silencieusement les montants qui ne sont pas multiples de 5 en XOF — détecter cela côté Laravel donne un message d’erreur lisible plutôt qu’un échec opaque. Le transaction_id est un ULID Laravel généré côté merchant, jamais réutilisé même après échec — réutiliser un identifiant créé un risque de collision côté CinetPay si l’ancien était finalement réussi. Le canal ALL par défaut laisse l’utilisateur choisir sa méthode sur l’écran CinetPay ; passer MOBILE_MONEY ou CREDIT_CARD force un canal unique pour les flux où vous savez d’avance ce que vous voulez (par exemple un paiement carte dédié pour les clients étrangers).

Ajoutez la configuration dans config/services.php :

'cinetpay' => [
    'api_key' => env('CINETPAY_API_KEY'),
    'site_id' => env('CINETPAY_SITE_ID'),
    'notify_url' => env('CINETPAY_NOTIFY_URL'),
    'return_url' => env('CINETPAY_RETURN_URL'),
],

Étape 4 — Initier un paiement multi-canal depuis un contrôleur

Le contrôleur expose deux endpoints applicatifs : un POST /checkout/cinetpay qui crée la transaction et redirige le client, et un GET /checkout/return qui gère le retour client après paiement. Le flux multi-canal est extrêmement simple : on choisit le canal (ALL, MOBILE_MONEY, CREDIT_CARD) selon ce que demande l’utilisateur et on laisse CinetPay gérer l’écran de saisie.

<?php
namespace App\Http\Controllers;

use App\Models\CinetPayTransaction;
use App\Services\CinetPayService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;

class CinetPayCheckoutController extends Controller
{
    public function __construct(private CinetPayService $cinetpay) {}

    public function initiate(Request $request)
    {
        $validated = $request->validate([
            'amount' => 'required|integer|min:100',
            'description' => 'required|string|max:255',
            'channels' => 'sometimes|in:ALL,MOBILE_MONEY,CREDIT_CARD,WALLET',
            'customer_phone_number' => 'sometimes|string|max:20',
        ]);
        $customer = [];
        if (! empty($validated['customer_phone_number'])) {
            $customer['customer_phone_number'] = $validated['customer_phone_number'];
            $customer['lock_phone_number'] = true;
        }
        $tx = $this->cinetpay->initialize(
            $validated['amount'],
            $validated['description'],
            $validated['channels'] ?? 'ALL',
            $customer,
        );
        $url = $tx->last_response['data']['payment_url'] ?? null;
        if (! $url) {
            return back()->withErrors(['payment' => 'Impossible d\'initialiser le paiement']);
        }
        return Redirect::away($url);
    }

    public function return(Request $request)
    {
        $transactionId = $request->query('transaction_id');
        if (! $transactionId) {
            return view('checkout.return', ['status' => 'unknown']);
        }
        $tx = CinetPayTransaction::where('transaction_id', $transactionId)->firstOrFail();
        if ($tx->status === 'pending') {
            $checked = $this->cinetpay->check($transactionId);
            $cinetpayStatus = $checked['data']['status'] ?? null;
            if ($cinetpayStatus === 'ACCEPTED') {
                $tx->update([
                    'status' => 'succeeded',
                    'operator' => $checked['data']['operator_id'] ?? null,
                    'phone_number' => $checked['data']['payment_method'] ?? null,
                    'last_response' => $checked,
                    'confirmed_at' => now(),
                ]);
            } elseif ($cinetpayStatus === 'REFUSED') {
                $tx->update([
                    'status' => 'failed',
                    'last_response' => $checked,
                ]);
            }
            // Tout autre statut (WAITING_FOR_CUSTOMER, etc.) : on laisse en pending — le cron de réconciliation reviendra
        }
        return view('checkout.return', ['transaction' => $tx]);
    }
}

L’astuce du lock_phone_number couplé à customer_phone_number est précieuse pour les flux où vous connaissez le numéro du client (compte connecté). Le client ne peut alors pas se tromper de numéro côté CinetPay et l’opérateur ne peut pas être confondu — le wallet utilisé est forcément celui du numéro fourni. Pour le canal CREDIT_CARD strict, il faut ajouter dans customer les autres champs requis (customer_id, customer_name, customer_surname, customer_email, customer_address, customer_city, customer_country en code ISO 3166-1 alpha-2, customer_state, customer_zip_code) — sans ces champs CinetPay refuse silencieusement le paiement carte.

Le retour client effectue le pull de réconciliation : si la transaction est encore pending côté Laravel, on appelle check() pour récupérer le statut courant. Cette double sécurité (webhook + pull) couvre les cas où le webhook CinetPay arrive en retard ou est perdu.

Étape 5 — Implémenter le handler webhook avec déduplication

Le webhook CinetPay arrive en POST sur la notify_url configurée. CinetPay envoie un payload qui contient le transaction_id, le statut, le payment_token et un hash de validation x-token. La vérification d’intégrité passe par le rappel à l’API check() après réception — comme pour PayDunya, c’est ce rappel qui valide que le webhook reçu correspond à un état réel côté CinetPay.

<?php
namespace App\Http\Controllers;

use App\Models\CinetPayTransaction;
use App\Models\WebhookEvent;
use App\Services\CinetPayService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class CinetPayWebhookController extends Controller
{
    public function handle(Request $request, CinetPayService $service)
    {
        $transactionId = $request->input('cpm_trans_id') ?? $request->input('transaction_id');
        if (! $transactionId) {
            return response('missing transaction id', 400);
        }
        $checked = $service->check($transactionId);
        $code = $checked['code'] ?? null;
        if ($code !== '00') {
            return response()->json(['error' => 'check failed', 'code' => $code], 400);
        }

        DB::transaction(function () use ($transactionId, $checked) {
            $event = WebhookEvent::firstOrCreate(
                ['external_id' => "cinetpay-{$transactionId}-".($checked['data']['operator_id'] ?? '')],
                ['provider' => 'cinetpay', 'type' => 'payment', 'payload' => $checked]
            );
            if (! $event->wasRecentlyCreated) return;
            $tx = CinetPayTransaction::where('transaction_id', $transactionId)->lockForUpdate()->first();
            if (! $tx) return;
            if (in_array($tx->status, ['succeeded', 'failed'])) return;
            $cinetpayStatus = $checked['data']['status'] ?? null;
            if ($cinetpayStatus === 'ACCEPTED') {
                $tx->update([
                    'status' => 'succeeded',
                    'operator' => $checked['data']['operator_id'] ?? null,
                    'phone_number' => $checked['data']['payment_method'] ?? null,
                    'last_response' => $checked,
                    'confirmed_at' => now(),
                ]);
            } elseif ($cinetpayStatus === 'REFUSED') {
                $tx->update(['status' => 'failed', 'last_response' => $checked]);
            }
            // Statut intermédiaire : on laisse en pending pour le cron de réconciliation
        });
        return response()->json(['received' => true]);
    }
}

La route doit être exemptée du middleware CSRF dans bootstrap/app.php :

->withMiddleware(function ($middleware) {
    $middleware->validateCsrfTokens(except: ['/webhooks/cinetpay']);
})

Le lockForUpdate() pose un verrou pessimiste sur la ligne cinetpay_transactions pour éviter qu’un webhook concurrent ne transitionne deux fois. Le pattern firstOrCreate sur webhook_events avec retour précoce si l’événement existe déjà garantit l’idempotence — un même webhook reçu deux fois ne déclenche les effets métier qu’une seule fois.

Étape 6 — Job de réconciliation périodique

Comme pour Wave, un cron de réconciliation balaie périodiquement les transactions bloquées en pending depuis plus de 10 minutes et appelle check() pour récupérer leur statut courant. Sans cela, une transaction où le webhook est perdu et où le client ne revient pas sur la page de retour reste indéfiniment en pending.

<?php
namespace App\Jobs;

use App\Models\CinetPayTransaction;
use App\Services\CinetPayService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ReconcileCinetPayJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(public CinetPayTransaction $tx) {}

    public function handle(CinetPayService $service): void
    {
        $checked = $service->check($this->tx->transaction_id);
        $status = $checked['data']['status'] ?? null;
        if ($status === 'ACCEPTED' && $this->tx->status === 'pending') {
            $this->tx->update([
                'status' => 'succeeded',
                'operator' => $checked['data']['operator_id'] ?? null,
                'last_response' => $checked,
                'confirmed_at' => now(),
            ]);
        } elseif ($status === 'REFUSED' && $this->tx->status === 'pending') {
            $this->tx->update(['status' => 'failed', 'last_response' => $checked]);
        }
    }
}

Et la planification dans routes/console.php :

Schedule::call(function () {
    CinetPayTransaction::where('status', 'pending')
        ->where('updated_at', '<', now()->subMinutes(10))
        ->each(fn ($tx) => ReconcileCinetPayJob::dispatch($tx));
})->everyFiveMinutes();

Le scheduler scrute toutes les cinq minutes les transactions bloquées et déclenche le job pour chacune. Le job appelle check() et transitionne en base si CinetPay rapporte un statut final. Aucune transaction ne reste indéfiniment en limbo.

Étape 7 — Tester en sandbox de bout en bout

Activez le mode test sur votre tableau de bord CinetPay (page « Test » dans les paramètres). Lancez Laravel et le tunnel HTTPS en parallèle.

php artisan serve &
cloudflared tunnel --url http://localhost:8000

Récupérez l’URL Cloudflare et mettez-la dans votre .env comme CINETPAY_NOTIFY_URL=https://xxx.trycloudflare.com/webhooks/cinetpay et CINETPAY_RETURN_URL=https://xxx.trycloudflare.com/checkout/return. Mettez à jour les URLs côté CinetPay également.

Soumettez un paiement de test avec un montant multiple de 5 (par exemple 1 000 XOF). Vous êtes redirigé sur la page CinetPay sandbox, qui propose les wallets de test. Choisissez un wallet, simulez la confirmation, et vérifiez que :

  • la transaction passe à succeeded côté Laravel ;
  • le webhook a bien créé une ligne dans webhook_events ;
  • l’operator est bien rempli en base (Wave, Orange Money selon ce que vous avez choisi).

Pour tester le canal carte, soumettez avec channels=CREDIT_CARD et tous les champs customer_* requis. CinetPay sandbox accepte les cartes de test Visa standard (4111 1111 1111 1111, date future quelconque, CVV 123).

Erreurs fréquentes

Erreur Cause Solution
code: 608 au moment de l’init Montant pas multiple de 5 Valider côté Laravel avant l’appel API
Paiement carte refusé sans message Champs customer_* incomplets Compléter id, name, surname, email, address, city, country, state, zip_code
Webhook reçu mais transaction pas mise à jour Pas de rappel à check() après réception Ajouter le check() dans le handler avant la mise à jour
Doublon de mise à jour Pas de firstOrCreate sur webhook_events Index unique + pattern firstOrCreate
transaction_id rejeté en collision Réutilisation après timeout Toujours générer un nouvel ULID, ne jamais retenter avec le même
401 Unauthorized apikey manquant ou décalé entre sandbox/prod Vérifier .env, vérifier le mode test côté tableau de bord
payment_url manquant dans la réponse code n’est pas 201 mais le contrôleur ne vérifie pas Vérifier data.payment_url, gérer l’absence proprement
Notification pas reçue en local Pas de tunnel HTTPS Cloudflare Tunnel + mise à jour de la notify_url côté tableau de bord

Tutoriels frères

FAQ

Quels pays CinetPay couvre-t-il en 2026 ?

Côte d’Ivoire, Sénégal, Burkina Faso, Mali, Cameroun, Togo, Bénin, Guinée Conakry, République Démocratique du Congo. Les devises supportées sont XOF, XAF, CDF, GNF, et USD pour les paiements internationaux par carte.

Quelle différence entre ALL et un canal spécifique ?

ALL affiche tous les moyens de paiement disponibles dans le pays sélectionné et laisse l’utilisateur choisir. MOBILE_MONEY, CREDIT_CARD, WALLET forcent un canal unique. Choisir MOBILE_MONEY quand on sait que l’utilisateur n’a pas de carte évite l’effort cognitif de l’écran de choix.

Le SDK seamless est-il vraiment sans redirection ?

Le SDK seamless de CinetPay charge un widget JavaScript dans votre page qui ouvre une popup côté CinetPay. Le client ne quitte pas votre domaine visuellement, mais en réalité un iframe vers les serveurs CinetPay traite le paiement. Pour des raisons de PCI, le seamless reste contraint sur certains canaux carte.

Combien de temps avant de recevoir l’argent sur mon compte ?

CinetPay crédite votre compte CinetPay quasi instantanément après une transaction ACCEPTED. Le retrait vers votre compte bancaire prend en général 24 à 72 heures. Les marchands à fort volume peuvent négocier un retrait quotidien automatique avec leur chargé de compte.

CinetPay supporte-t-il les abonnements récurrents ?

CinetPay propose une fonctionnalité d’abonnement à activer via le tableau de bord. Le contrat technique passe par un endpoint séparé de la checkout API, qui crée un mandat de prélèvement répété sur le wallet du client. Référez-vous à la documentation pour les détails actuels.

Comment gérer les remboursements ?

CinetPay expose un workflow de remboursement depuis le tableau de bord administrateur ou via API. Le délai de remboursement effectif dépend du moyen de paiement initial — instantané pour Wave/Orange Money, plusieurs jours pour les cartes bancaires.

Pour aller plus loin

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité