ITSkillsCenter
Blog

Wave Payout API en Laravel : verser un paiement mobile pas-à-pas

17 دقائق للقراءة

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

Introduction

Verser de l’argent en mobile money depuis une application Laravel est un besoin récurrent : marketplace qui paie ses vendeurs, plateforme freelance qui distribue les commissions, application logistique qui rémunère les livreurs à la course, application RH qui paie les primes de fin de mois. La Wave Payout API permet de pousser ces versements en quelques lignes de code, mais faire les choses bien — idempotence, retry, audit, sécurité — demande une architecture précise. Ce tutoriel construit une intégration de production pas-à-pas en Laravel 11.

Prérequis

  • PHP 8.3+ et Composer 2.7+
  • Laravel 11.x installé
  • Un compte Wave Business validé en mode marchand
  • Accès au tableau de bord Wave Business Portal pour générer la clé API
  • Niveau intermédiaire en Laravel (Service Container, Jobs, Migrations)
  • Temps estimé : 2 heures pour le code, 1 jour pour valider en sandbox

Étape 1 — Créer un compte Wave Business et générer la clé API

Tout démarre par un compte marchand validé. Sans cela, les appels à l’API échouent avec un 401 quel que soit votre code. Rendez-vous sur le portail business Wave et inscrivez votre entreprise — statut juridique, NINEA pour le Sénégal, RIB pour les versements depuis votre balance Wave, justificatifs d’activité. La validation prend en général deux semaines après dépôt complet. Pendant ce temps, vous pouvez utiliser le mode sandbox du portail développeur pour avancer sur le code.

Une fois le compte validé, naviguez vers la section développeurs du portail et créez une clé API restreinte au scope Payout. Donnez-lui un nom lisible (prod-payout-laravel-v1 par exemple) qui facilite la rotation ultérieure. Notez la clé : elle ne sera affichée qu’une seule fois. Si vous la perdez, vous devrez en créer une nouvelle et désactiver l’ancienne.

Cette clé est sensible : elle autorise des virements depuis votre balance. Stockez-la dans le .env de votre projet et n’incluez jamais ce fichier dans le dépôt Git. La rotation doit être planifiée trimestriellement et exécutée à chaque départ d’un développeur ayant eu accès à l’environnement de production.

Étape 2 — Préparer le projet Laravel et la table d’audit

Avant le code métier, il faut une table qui trace chaque tentative de payout. Sans cela, vous ne pouvez ni déduire un état après crash, ni répondre à un audit comptable. La table porte trois informations critiques : l’identifiant interne de la transaction côté Laravel (jamais réutilisé), l’identifiant retourné par Wave après création (utile pour réconcilier), et le statut courant qui reflète celui de Wave.

Créez la migration avec la commande Artisan habituelle :

php artisan make:migration create_wave_payouts_table

La commande génère un fichier dans database/migrations/ que vous éditez pour décrire les colonnes. Le client_reference est notre identifiant interne ; le wave_payout_id est rempli après l’appel API ; les statuts suivent l’énumération Wave (processing, succeeded, failed, reversed) plus deux états locaux (pending avant l’appel, error si l’appel a échoué techniquement avant même de créer un payout côté Wave).

<?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('wave_payouts', function (Blueprint $table) {
            $table->id();
            $table->string('client_reference', 64)->unique();
            $table->string('wave_payout_id', 64)->nullable()->unique();
            $table->string('mobile', 20);
            $table->unsignedInteger('amount_xof');
            $table->string('status', 20)->default('pending');
            $table->string('payment_reason', 40)->nullable();
            $table->json('last_response')->nullable();
            $table->timestamp('confirmed_at')->nullable();
            $table->timestamps();
            $table->index(['status', 'created_at']);
        });
    }

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

Lancez la migration avec php artisan migrate. La table est maintenant prête à journaliser chaque payout. L’index composite sur status et created_at accélère le scan périodique des payouts bloqués en processing plus de 10 minutes — c’est le filet de sécurité qui détecte les webhooks manqués.

Étape 3 — Implémenter le service WavePayoutService

Le service centralise l’appel à l’API Wave et la persistance en base. L’isoler dans une classe dédiée permet de le mocker en test, de le réutiliser depuis n’importe quel contrôleur ou job, et de le faire évoluer sans toucher au reste du code. On utilise le client HTTP natif de Laravel (Illuminate\Support\Facades\Http) qui gère pour nous les retry réseau et l’inspection des réponses.

<?php
namespace App\Services;

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

class WavePayoutService
{
    public function __construct(
        private string $apiKey = '',
        private string $baseUrl = 'https://api.wave.com',
    ) {
        $this->apiKey = config('services.wave.api_key');
    }

    public function send(string $mobileE164, int $amountXof, ?string $reason = null): WavePayout
    {
        $clientReference = (string) Str::ulid();

        $payout = WavePayout::create([
            'client_reference' => $clientReference,
            'mobile' => $mobileE164,
            'amount_xof' => $amountXof,
            'payment_reason' => $reason,
            'status' => 'pending',
        ]);

        $response = Http::withToken($this->apiKey)
            ->withHeaders(['Idempotency-Key' => $clientReference])
            ->acceptJson()
            ->asJson()
            ->timeout(20)
            ->post("{$this->baseUrl}/v1/payout", [
                'currency' => 'XOF',
                'receive_amount' => (string) $amountXof,
                'mobile' => $mobileE164,
                'client_reference' => $clientReference,
                'payment_reason' => $reason,
            ]);

        $payout->update([
            'wave_payout_id' => $response->json('id'),
            'status' => $response->json('status', 'error'),
            'last_response' => $response->json(),
        ]);

        return $payout->refresh();
    }
}

Quatre détails du code méritent attention. Le Idempotency-Key est obligatoire sur POST /v1/payout — la documentation Wave est explicite : « An Idempotency-Key header is required for POST requests ». Réutiliser le même ULID en cas de retry réseau garantit que Wave ne créera pas un second payout pour la même demande. Le client_reference est ce même ULID porté en plus dans le body — il sert d’identifiant côté merchant et apparaît dans les rapports Wave Business Portal. Le montant est passé en chaîne de caractères ((string) $amountXof) pour respecter le contrat Wave qui interdit les nombres décimaux. Le timeout est fixé à 20 secondes — au-delà, vous laissez votre file d’attente HTTP s’engorger pour rien.

L’appel met à jour la base avec l’identifiant Wave et le statut retourné. Ce statut est typiquement processing au moment de la réponse synchrone — la confirmation arrive plus tard via webhook. Si l’appel échoue (404 sur la route, 500 côté Wave, timeout réseau), le statut reste à error et le job de retry de l’étape suivante prendra le relais.

Pensez à enregistrer le service dans config/services.php :

'wave' => [
    'api_key' => env('WAVE_API_KEY'),
    'webhook_secret' => env('WAVE_WEBHOOK_SECRET'),
],

Et à compléter votre .env avec les valeurs sandbox tant que les clés de production ne sont pas délivrées. Les valeurs sandbox sont disponibles dans la même section développeurs du Wave Business Portal.

Étape 4 — Job de retry asynchrone et idempotence

L’appel synchrone précédent est suffisant pour la création initiale, mais il ne couvre pas le cas où le webhook tarde ou se perd. Le filet de sécurité est un job qui scanne périodiquement les payouts en processing depuis plus de N minutes et appelle GET /v1/payout/:id pour récupérer le statut réel. C’est la philosophie « webhook plus pull » : le webhook est rapide quand il arrive, le pull garantit qu’aucune transaction ne reste en limbe.

Créez le job avec Artisan :

php artisan make:job ReconcileWavePayoutJob

Le job reçoit un WavePayout en paramètre, appelle l’API Wave pour récupérer le statut courant, met à jour la base si le statut a changé, et se laisse rejouer s’il échoue techniquement. La signature ShouldQueue couplée à InteractsWithQueue rend le job rejouable et observable depuis Horizon ou n’importe quel runner Laravel.

<?php
namespace App\Jobs;

use App\Models\WavePayout;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;

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

    public int $tries = 5;
    public array $backoff = [30, 60, 300, 900, 1800];

    public function __construct(public WavePayout $payout) {}

    public function handle(): void
    {
        if (! $this->payout->wave_payout_id) {
            return;
        }
        $response = Http::withToken(config('services.wave.api_key'))
            ->acceptJson()
            ->timeout(15)
            ->get("https://api.wave.com/v1/payout/{$this->payout->wave_payout_id}");

        if ($response->successful()) {
            $newStatus = $response->json('status');
            if ($newStatus !== $this->payout->status) {
                $this->payout->update([
                    'status' => $newStatus,
                    'last_response' => $response->json(),
                    'confirmed_at' => in_array($newStatus, ['succeeded', 'failed', 'reversed'])
                        ? now() : null,
                ]);
            }
        }
    }
}

Le backoff exponentiel évite de matraquer l’API Wave en cas de panne temporaire — premier retry à 30 secondes, dernier à 30 minutes, après quoi le job échoue et est visible dans Horizon pour intervention manuelle. Le job ne transitionne en base que si le statut a réellement changé, ce qui évite les écritures inutiles et préserve la cohérence du confirmed_at.

Pour planifier le scan automatique des payouts bloqués, utilisez le scheduler Laravel dans routes/console.php :

<?php
use App\Jobs\ReconcileWavePayoutJob;
use App\Models\WavePayout;
use Illuminate\Support\Facades\Schedule;

Schedule::call(function () {
    WavePayout::where('status', 'processing')
        ->where('updated_at', '<', now()->subMinutes(10))
        ->each(fn ($p) => ReconcileWavePayoutJob::dispatch($p));
})->everyFiveMinutes();

Toutes les cinq minutes, le scheduler identifie les payouts en processing non touchés depuis plus de 10 minutes et déclenche un job de réconciliation. Le résultat : aucun payout ne reste indéfiniment dans un état intermédiaire, même si le webhook a été perdu.

Étape 5 — Webhook avec vérification HMAC SHA-256

Le webhook Wave porte la confirmation officielle du statut final. C’est lui qui doit déclencher les effets métier : marquer la commission comme versée, envoyer un SMS de confirmation au bénéficiaire, mettre à jour le tableau de bord administrateur. Avant tout, il faut s’assurer que le webhook reçu vient bien de Wave — sans cette vérification, n’importe qui peut forger un appel à votre route et déclencher une fausse confirmation.

Wave signe chaque webhook avec un header Wave-Signature: t=<timestamp>,v1=<hmac> où le HMAC SHA-256 est calculé sur la concaténation du timestamp et du corps brut de la requête, avec votre webhook_secret comme clé. La vérification doit travailler sur le corps brut — le JSON parsé puis re-sérialisé donne une signature différente parce que l’ordre des clés et les espaces ne sont pas garantis.

Le contrôleur webhook expose une route POST /webhooks/wave exemptée du middleware CSRF (à déclarer dans bootstrap/app.php via validateCsrfTokens excluding cette URI), récupère le corps brut via $request->getContent(), vérifie la signature, déduplique l’événement par identifiant, puis met à jour le payout.

<?php
namespace App\Http\Controllers;

use App\Models\WavePayout;
use App\Models\WebhookEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class WaveWebhookController extends Controller
{
    public function handle(Request $request)
    {
        $signature = $request->header('Wave-Signature');
        $rawBody = $request->getContent();

        if (! $this->isValidSignature($signature, $rawBody)) {
            return response('invalid signature', 401);
        }

        $payload = json_decode($rawBody, true);
        $eventId = $payload['id'] ?? null;
        $eventType = $payload['type'] ?? null;

        if (! $eventId || ! $eventType) {
            return response('malformed', 400);
        }

        DB::transaction(function () use ($eventId, $eventType, $payload) {
            $event = WebhookEvent::firstOrCreate(
                ['external_id' => $eventId],
                ['provider' => 'wave', 'type' => $eventType, 'payload' => $payload]
            );
            if (! $event->wasRecentlyCreated) {
                return;
            }
            $payoutId = $payload['data']['id'] ?? null;
            $newStatus = $payload['data']['status'] ?? null;
            if ($payoutId && $newStatus) {
                WavePayout::where('wave_payout_id', $payoutId)->update([
                    'status' => $newStatus,
                    'last_response' => $payload['data'],
                    'confirmed_at' => in_array($newStatus, ['succeeded', 'failed', 'reversed'])
                        ? now() : null,
                ]);
            }
        });

        return response()->json(['received' => true]);
    }

    private function isValidSignature(?string $header, string $body): bool
    {
        if (! $header) return false;
        $parts = explode(',', $header);
        $timestamp = null; $signatures = [];
        foreach ($parts as $part) {
            [$k, $v] = explode('=', $part, 2);
            if ($k === 't') $timestamp = $v;
            if ($k === 'v1') $signatures[] = $v;
        }
        if (! $timestamp || empty($signatures)) return false;
        if (abs(time() - (int) $timestamp) > 300) return false;
        $expected = hash_hmac('sha256', $timestamp.$body, config('services.wave.webhook_secret'));
        foreach ($signatures as $sig) {
            if (hash_equals($expected, $sig)) return true;
        }
        return false;
    }
}

La vérification de timestamp à 300 secondes (5 minutes) bloque les rejeux d’un attaquant qui aurait capté un webhook légitime hier. La déduplication par WebhookEvent (table dédiée à créer avec un index unique sur external_id) garantit qu’un même événement reçu deux fois ne déclenche les effets métier qu’une seule fois — Wave réessaie en cas de timeout côté marchand, et sans idempotence vous double-envoyez le SMS au bénéficiaire.

La mise à jour est encapsulée dans une transaction Laravel : si quoi que ce soit échoue après la création de l’événement, le rollback rejoue tout depuis zéro. Le retour 200 est essentiel — Wave considère un webhook acquitté quand il reçoit un statut HTTP 2xx, et continuera à le rejouer tant qu’il ne l’a pas. Une réponse 5xx déclenche l’exponential backoff de Wave qui peut retarder de plusieurs minutes la prochaine tentative.

Étape 6 — Vérification du destinataire avant payout

Verser à un mauvais numéro de téléphone est une erreur coûteuse : Wave ne peut pas annuler un payout succeeded automatiquement, et le récupérer demande de contacter manuellement le bénéficiaire ou le support Wave. Pour éviter cette catégorie d’erreurs, la Wave Recipient Info API permet de vérifier qu’un numéro est bien enregistré sur Wave avant de pousser le virement.

L’endpoint exact est POST https://api.wave.com/v1/verify_recipient/ et accepte le numéro au format E.164 plus le montant et la devise pour vérifier d’un coup que le destinataire existe, que le nom correspond, et que la transaction respectera les limites du portefeuille du destinataire. Le B2B Recipient Info API à GET /v1/b2b/recipient-info est un autre endpoint, encore en statut « upcoming » dans la documentation, dédié aux flux marchand-vers-marchand identifiés par compte ou QR code. La limitation à 30 vérifications par numéro sur une fenêtre de 5 minutes empêche d’utiliser cette API comme un annuaire — c’est un garde-fou avant payout, pas un outil de découverte.

L’intégration côté service ressemble à un appel HTTP simple : on appelle l’endpoint avec le numéro, on récupère le nom, on le compare à celui qu’on attend (typiquement le nom du vendeur ou du livreur dans votre base de données), et on autorise le payout uniquement si la correspondance est satisfaisante. La tolérance de comparaison dépend de votre métier : pour une marketplace régulée, exigez une correspondance exacte sur le nom et prénom ; pour une plateforme moins critique, une distance de Levenshtein faible peut suffire. Cette étape de validation se place avant le Http::post('/v1/payout') du service initial — si elle échoue, le payout n’est même pas tenté et l’opérateur backoffice voit la divergence dans son tableau de bord pour arbitrer.

Étape 7 — Tester en sandbox de bout en bout

Le test manuel passe par trois assertions à confirmer en environnement sandbox avant de basculer sur la clé production. La première : un payout créé via votre route applicative apparaît bien en base avec son wave_payout_id rempli et son statut à processing. Vérifiez avec php artisan tinker ou directement en SQL.

La seconde : le webhook simulé depuis le portail Wave (la plupart des passerelles offrent un bouton « envoyer un événement de test ») met à jour le statut en succeeded et déclenche les effets métier attendus. Loguez chaque étape pour voir où ça coince si la transition n’a pas lieu.

La troisième : le job de réconciliation, déclenché manuellement via php artisan tinker puis dispatch_sync(new ReconcileWavePayoutJob($payout)), récupère bien le statut depuis l’API Wave et met à jour la base si le webhook avait été manqué. Cette assertion garantit que votre filet de sécurité fonctionne.

Pour exposer votre serveur de dev sur HTTPS public — nécessaire pour que Wave atteigne votre webhook — utilisez Cloudflare Tunnel (gratuit, stable) ou ngrok. La commande cloudflared tunnel --url http://localhost:8000 génère une URL HTTPS publique que vous renseignez dans la configuration sandbox du Wave Business Portal sous « Webhooks ». Toute requête de Wave arrive ainsi sur votre laptop comme si vous étiez en production.

Erreurs fréquentes

Erreur Cause Solution
401 sur /v1/payout Clé API manquante ou mauvaise restriction de scope Vérifier le .env, vérifier que la clé est bien configurée pour le scope Payout
422 sur mobile Numéro pas au format E.164 Préfixer +221 (ou autre indicatif pays) systématiquement avant l’appel
Webhook reçu mais signature invalide Le middleware Laravel re-sérialise le JSON avant le contrôleur Lire $request->getContent() (raw body) avant tout parsing, vérifier la signature dessus
Payout reste en processing indéfiniment Webhook perdu et job de réconciliation pas planifié Activer le Schedule::everyFiveMinutes() dans routes/console.php
Double envoi de SMS au bénéficiaire Pas d’idempotence sur l’événement webhook Table webhook_events avec index unique sur external_id + firstOrCreate
429 Too Many Requests Trop de payouts créés en boucle dans une même seconde Throttler côté job ou batch via POST /v1/payout-batch pour les volumes

Tutoriels frères

FAQ

Combien de temps avant l’arrivée effective de l’argent chez le bénéficiaire ?

En sandbox, la confirmation est typiquement instantanée. En production, le statut succeeded est rendu en quelques secondes à quelques minutes selon la charge de l’opérateur, et l’argent est crédité sur le portefeuille Wave du bénéficiaire dès la confirmation.

Peut-on annuler un payout déjà confirmé ?

Wave expose POST /v1/payout/:id/reverse pour les payouts en succeeded. Le succès du reverse dépend de l’état du portefeuille du bénéficiaire — si l’argent a été dépensé ou retiré, le reverse échoue et la récupération demande un dialogue manuel avec Wave.

Faut-il signer les requêtes sortantes en plus du webhook entrant ?

Pour Wave, l’authentification par Bearer token sur HTTPS est suffisante par défaut. Le request signing optionnel via Wave-Signature ajoute une couche supplémentaire et est recommandé si vous appelez l’API depuis un environnement multi-tenant. En revanche, le header Idempotency-Key est obligatoire sur toutes les requêtes POST qui modifient l’état (payout, refund, expire) — pas optionnel.

Comment auditer toutes les opérations à des fins comptables ?

La table wave_payouts couplée à la table webhook_events couvre la traçabilité bout-en-bout. Conservez ces tables en archive pendant au moins 5 ans pour les obligations fiscales locales. Une vue SQL qui joint les deux tables sur wave_payout_id produit le journal d’audit prêt pour l’inspection.

Que faire si Wave envoie un statut inconnu ?

Le contrat actuel ne prévoit que processing, succeeded, failed, reversed. Si un statut inconnu arrive, journalisez-le dans last_response, ne transitionnez pas l’état applicatif, et alertez l’opérateur backoffice. Cela protège contre une évolution silencieuse de l’API.

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é