Fintech

Wave Payout API en Laravel : verser un paiement mobile

Verser de l'argent vers un wallet mobile avec la Wave Payout API en Laravel : POST /v1/payout, clé d'idempotence anti-double-paiement, webhook signé Wave-Signature et endpoint de versement verrouillé.

📍 Article principal du cluster : Mobile money en backend : Wave, Orange Money, PayDunya, CinetPay. Pour la distinction encaissement / versement et la sécurité, lisez d’abord le pilier.

Jusqu’ici, tout le cluster parlait d’encaisser. On inverse le flux : ici, vous versez de l’argent vers un portefeuille mobile. C’est le besoin d’une marketplace qui paie ses vendeurs, d’un service de livraison qui règle ses coursiers, ou d’une entreprise qui verse des remboursements. La Wave Payout API fait exactement ça. Et comme vous envoyez de l’argent réel, une erreur n’est plus un bug : c’est un double virement. À la fin de ce tutoriel, votre application Laravel déclenchera des versements Wave de façon idempotente — impossible de payer deux fois le même bénéficiaire — et suivra leur statut via webhook signé.

Ce que vous allez apprendre

  • Déclencher un versement vers un wallet via POST /v1/payout ;
  • Utiliser une clé d’idempotence pour ne jamais doubler un paiement, même après une coupure réseau ;
  • Vérifier la signature Wave-Signature des webhooks pour suivre le statut réel ;
  • Sécuriser l’endpoint qui déclenche des versements — le point le plus sensible de toute l’app ;
  • Gérer l’échec et la réconciliation d’un versement.

Ce que vous allez construire

Le fil rouge : une marketplace Laravel qui reverse à ses vendeurs. Un job VerserVendeur appelle Wave avec une clé d’idempotence stable, enregistre le versement en processing, et un webhook le fait passer succeeded ou failed. Concret : relancer le job dix fois ne paie le vendeur qu’une seule fois.

Prérequis

  • PHP 8.2+, un projet Laravel 11, et une file de jobs configurée (base ou Redis).
  • Un compte Wave Business avec une clé d’API liée à votre wallet (les fonds versés en partent).
  • Le secret de signature des webhooks Wave.
  • Un domaine HTTPS public pour recevoir les webhooks.
  • ⏱️ Temps estimé : ~50 minutes. Test express : si vous savez écrire un job et un contrôleur Laravel, vous êtes prêt.

Le versement, ce n’est pas l’encaissement

Une différence change tout le raisonnement sécurité. Quand vous encaissez, le pire d’une faille, c’est livrer une commande non payée. Quand vous versez, le pire, c’est envoyer de l’argent qui ne reviendra pas. Deux conséquences directes : l’endpoint qui déclenche un versement doit être verrouillé bien plus sévèrement qu’un simple paiement, et chaque appel doit être idempotent, car un double versement est une perte sèche immédiate. Gardez ces deux idées en tête tout au long.

Étape 1 — Configurer l’accès Wave

Comme toujours, les secrets vivent dans l’environnement, jamais dans le code. La clé d’API Wave est liée à un wallet précis : c’est de ce wallet que partiront les fonds, traitez-la comme un trousseau de coffre-fort.

# .env
WAVE_API_KEY=wave_sn_prod_xxxxxxxx
WAVE_WEBHOOK_SECRET=wave_whsec_xxxxxxxx
WAVE_BASE_URL=https://api.wave.com/v1
// config/services.php
'wave' => [
    'key'            => env('WAVE_API_KEY'),
    'webhook_secret' => env('WAVE_WEBHOOK_SECRET'),
    'base'           => env('WAVE_BASE_URL', 'https://api.wave.com/v1'),
],

La séparation clé d’API / secret de webhook n’est pas un détail : la première sert à envoyer de l’argent, le second à vérifier les notifications entrantes. Deux rôles, deux secrets.

Étape 2 — Un modèle qui porte la clé d’idempotence

Chaque versement est tracé en base avant même l’appel à Wave. Le champ décisif est idempotency_key : une valeur unique et stable qu’on rejouera à l’identique en cas de réessai. C’est elle qui garantit qu’un même versement logique ne part qu’une fois.

// migration
Schema::create('payouts', function (Blueprint $table) {
    $table->id();
    $table->string('idempotency_key')->unique();
    $table->foreignId('vendeur_id')->constrained();
    $table->unsignedInteger('amount');     // entier XOF
    $table->string('mobile');
    $table->string('wave_id')->nullable(); // id renvoyé par Wave
    $table->string('status')->default('pending'); // pending|processing|succeeded|failed
    $table->timestamps();
});

Le unique() sur idempotency_key est une double assurance : même si votre code se trompait, la base refuserait deux versements portant la même clé. On génère cette clé une fois, à la création du Payout, et on ne la régénère jamais.

Étape 3 — Déclencher le versement

On place l’appel dans un job, pour qu’il soit relançable proprement en cas d’échec. Le cœur du sujet : l’en-tête Idempotency-Key. Wave est explicite à ce propos — si vous renvoyez la même requête avec une clé différente, vous créez un doublon ; si vous la renvoyez avec la même clé, Wave reconnaît l’opération et ne verse qu’une fois. On rejoue donc toujours avec la clé stockée.

// app/Jobs/VerserVendeur.php
use IlluminateSupportFacadesHttp;

public function handle(): void
{
    $payout = $this->payout;

    if ($payout->status !== 'pending') {
        return; // deja parti : on ne retente pas a l'aveugle
    }

    $res = Http::withToken(config('services.wave.key'))
        ->withHeaders(['Idempotency-Key' => $payout->idempotency_key])
        ->post(config('services.wave.base').'/payout', [
            'currency'        => 'XOF',
            'receive_amount'  => (string) $payout->amount,
            'mobile'          => $payout->mobile,    // ex : +221761234567
            'name'            => $payout->vendeur->name,
            'client_reference'=> 'PAYOUT-'.$payout->id,
        ]);

    if ($res->successful()) {
        $payout->update([
            'wave_id' => $res->json('id'),
            'status'  => $res->json('status'), // 'processing' le plus souvent
        ]);
    } else {
        // 4xx : on logge, on n'efface PAS la cle (le rejeu doit la reutiliser)
        $payout->update(['status' => 'failed']);
    }
}

Le statut initial renvoyé par Wave est généralement processing : le versement est accepté mais pas encore confirmé. receive_amount est le montant que le bénéficiaire recevra, exprimé en chaîne et en entier (XOF). Et le garde status !== 'pending' évite qu’un rejeu du job ne relance un versement déjà engagé. La confirmation, elle, viendra par webhook.

Un réflexe sain avant de verser en masse : vérifier le solde du wallet d’où partent les fonds, via la Balance API de Wave (GET /v1/balance). Rien de pire qu’une série de versements qui échouent à mi-chemin faute de provision. En pratique, on contrôle le solde avant de lancer un lot, et on alerte l’équipe finance si la couverture est insuffisante.

Pourquoi un job plutôt qu’un appel direct ? Parce qu’un versement est une opération réseau qui peut échouer pour mille raisons passagères. Dans une file, Laravel rejoue le job automatiquement, avec un délai croissant ; et grâce à la clé d’idempotence, ces rejeux ne créent jamais de doublon. Vous obtenez la robustesse sans le risque.

Étape 4 — Le webhook : vérifier la signature

Wave notifie l’issue (payout.succeeded, payout.failed) sur votre endpoint, avec un en-tête Wave-Signature au format t=horodatage,v1=signature. On recompose la signature attendue (HMAC-SHA256 de « horodatage.corps » avec le secret de webhook) et on rejette tout ce qui ne correspond pas. Sans cette vérification, n’importe qui pourrait vous annoncer un faux succeeded.

// app/Http/Controllers/WaveWebhookController.php
use IlluminateHttpRequest;

public function handle(Request $request)
{
    $header = $request->header('Wave-Signature', '');
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
        $parts[$k] = $v;
    }

    $payload  = ($parts['t'] ?? '').'.'.$request->getContent();
    $expected = hash_hmac('sha256', $payload, config('services.wave.webhook_secret'));

    if (! hash_equals($expected, $parts['v1'] ?? '')) {
        return response('signature invalide', 400);
    }

    $event  = $request->json('type');           // payout.succeeded | payout.failed
    $waveId = $request->json('data.id');
    $payout = Payout::where('wave_id', $waveId)->first();

    if ($payout && in_array($payout->status, ['pending', 'processing'])) {
        $payout->update([
            'status' => $event === 'payout.succeeded' ? 'succeeded' : 'failed',
        ]);
    }
    return response('', 200);
}

hash_equals compare les signatures en temps constant — on n’utilise jamais == pour comparer des secrets. Le test in_array($payout->status, ['pending','processing']) assure l’idempotence : un webhook rejoué sur un versement déjà succeeded ne refait rien. On vérifie d’abord, on agit ensuite.

Étape 5 — Suivre le statut et annuler si besoin

Si un webhook se perd, vous pouvez interroger l’état d’un versement avec GET /v1/payout/{id}. Wave permet aussi d’annuler un versement encore réversible via POST /v1/payout/{id}/reverse — utile si vous détectez une erreur de bénéficiaire avant l’aboutissement. Comme pour l’encaissement, prévoyez un job de réconciliation qui repasse sur les versements restés processing trop longtemps et appelle l’API pour trancher.

Quand un versement finit failed, ne le rejouez pas en boucle aveuglément : un échec peut venir d’un numéro invalide ou d’un solde insuffisant, et réessayer ne changera rien tant que la cause n’est pas corrigée. Distinguez les échecs transitoires (réseau) des échecs définitifs (numéro erroné), et n’alertez un humain que pour les seconds.

Point d’étape — Déclenchez un versement de test. Vous devez voir un Payout passer pendingprocessing (réponse de l’API) → succeeded (webhook). Relancez le job : aucun second versement ne doit partir, grâce à la clé d’idempotence.

Étape 6 — Verrouiller l’endpoint de versement

C’est l’étape qu’on oublie et qui coûte le plus cher. L’action « verser » ne doit jamais être déclenchable par une simple requête non authentifiée. Concrètement : protégez la route derrière l’authentification et une autorisation explicite (seul un rôle « finance » déclenche un versement), validez le bénéficiaire et le montant côté serveur, et plafonnez les montants. Un versement, ça se déclenche depuis votre back-office sécurisé ou un job interne — jamais depuis une route publique.

// routes/web.php — versement reserve, jamais public
Route::post('/admin/payouts/{vendeur}', [PayoutController::class, 'verser'])
    ->middleware(['auth', 'can:gerer-paiements']);

Le middleware can:gerer-paiements impose une autorisation Laravel (policy/gate) : être connecté ne suffit pas, il faut le droit explicite. Sur une opération qui sort de l’argent, cette barrière vaut tous les pare-feu.

Le contrôleur derrière cette route reste mince : il valide, crée le Payout avec sa clé d’idempotence, et met le job en file. Tout le travail risqué — l’appel réseau — se passe dans le job, relançable, jamais dans le cycle de la requête HTTP.

// PayoutController.php
use IlluminateSupportStr;

public function verser(Request $request, Vendeur $vendeur)
{
    $data = $request->validate([
        'amount' => ['required', 'integer', 'min:100'],
    ]);

    $payout = Payout::create([
        'idempotency_key' => (string) Str::uuid(),  // genere UNE seule fois
        'vendeur_id'      => $vendeur->id,
        'amount'          => $data['amount'],
        'mobile'          => $vendeur->mobile,
        'status'          => 'pending',
    ]);

    VerserVendeur::dispatch($payout);
    return back()->with('success', 'Versement programme.');
}

La clé d’idempotence est générée ici, une seule fois, à la création du Payout : le job la rejouera telle quelle à chaque tentative. C’est précisément ce qui rend l’ensemble sûr face aux réessais.

🐞 Pièges fréquents

Symptôme Cause probable Correctif
Double versement Clé d’idempotence régénérée à chaque tentative Générer la clé une fois, la rejouer à l’identique
Webhook accepté à tort Signature non vérifiée Recalculer le HMAC et hash_equals
Versement bloqué en processing Webhook perdu GET /v1/payout/{id} via un job de réconciliation
401 sur l’appel Clé d’API invalide / mauvais wallet Vérifier WAVE_API_KEY et son wallet
Route de versement exposée Pas d’autorisation Middleware auth + can:

🌍 Adaptation au contexte ouest-africain

Le payout répond à un besoin très concret ici : payer des dizaines de coursiers ou de vendeurs sur leur Wave, sans passer par des espèces ni des virements bancaires lents. Les numéros sont au format international (+221…), les montants en entiers XOF, et Wave propose aussi un endpoint de versement par lot (/v1/payout-batch) quand vous payez beaucoup de bénéficiaires d’un coup — chaque ligne avec sa propre référence. Gardez le réflexe réconciliation pour les réseaux instables, et journalisez chaque versement : sur de l’argent sortant, la traçabilité n’est pas optionnelle.

✅ Récapitulatif

Vous savez maintenant verser, pas seulement encaisser : un appel POST /v1/payout rendu idempotent par une clé stable, un webhook dont on vérifie la signature avant d’agir, un suivi de statut avec réconciliation, et — le plus important — un endpoint de déclenchement verrouillé par authentification et autorisation. C’est l’autre moitié du paiement mobile, celle qui fait sortir l’argent, traitée avec la prudence qu’elle exige.

🧾 Aide-mémoire

Élément Rôle
POST /v1/payout Déclencher un versement (en-tête Idempotency-Key obligatoire en pratique)
GET /v1/payout/{id} Vérifier le statut d’un versement
POST /v1/payout/{id}/reverse Annuler un versement réversible
/v1/payout-batch Verser à plusieurs bénéficiaires en une fois
Wave-Signature t=…,v1=… à vérifier par HMAC-SHA256

💪 À vous de jouer

Implémentez le versement par lot : au lieu d’un job par vendeur, regroupez les versements du jour et envoyez-les via /v1/payout-batch, chaque ligne avec sa propre référence et le statut suivi individuellement par webhook.

Voir une piste

Construisez un tableau de bénéficiaires (mobile, montant, référence unique), envoyez-le au endpoint batch avec une clé d’idempotence pour le lot entier, puis stockez l’identifiant de lot. À la réception des webhooks payout.*, mettez à jour chaque ligne par sa référence — la même logique de vérification de signature s’applique.

Tutoriels frères

Pour aller plus loin

FAQ

Que se passe-t-il si je rejoue le job après une coupure réseau ?
Tant que vous renvoyez la même clé d’idempotence, Wave reconnaît l’opération et ne verse pas une seconde fois. C’est tout l’intérêt de stocker la clé en base avant l’appel.

Encaissement et versement, même clé d’API ?
Pas forcément : les clés Wave sont liées à un wallet et à des droits précis. Utilisez une clé dédiée au payout, avec le périmètre minimal nécessaire.

Comment éviter de verser à un mauvais numéro ?
Validez le bénéficiaire côté serveur avant l’appel, plafonnez les montants, et exigez une autorisation explicite. En cas d’erreur détectée à temps, l’endpoint reverse peut annuler un versement encore réversible.

Wave facture-t-il des frais sur les versements ?
Oui, des frais s’appliquent côté émetteur ; la réponse de l’API en expose le détail (champ fee). Intégrez-les à vos calculs de coût, surtout en versement par lot où ils s’additionnent.

Puis-je tester les versements sans envoyer d’argent réel ?
Oui : utilisez une clé d’API de test liée à un wallet sandbox. Validez le cycle complet — déclenchement, webhook signé, statut final — avant de basculer sur la clé de production. Et confirmez toujours qu’un rejeu ne déclenche pas un second versement.

Mots-clés : Wave Payout API, versement mobile Laravel, payout Afrique, idempotence paiement, Wave-Signature webhook, /v1/payout.

Malick Diallo

Rédaction SenTur

Contributeur SenTur — passionné de tech et de transmission.

Aucun commentaire pour l'instant — lancez la discussion !

Laisser un commentaire

Votre adresse email ne sera pas publiée. La discussion est modérée — restez courtois et constructif.