La question revient à chaque intégration : « mon client dit qu'il a payé, comment je vérifie ? » La réponse courte : vous demandez à l'API, et à elle seule. Tout le reste — capture d'écran, SMS transféré, retour de navigateur, notification entrante — est au mieux un indice, au pire un piège.
Trois preuves qui n'en sont pas
La capture d'écran. Elle se fabrique en trente secondes avec n'importe quel éditeur d'images, et les modèles circulent. Un montant, un nom, une heure : tout est modifiable. Une capture ne prouve rien.
Le SMS de l'opérateur transféré. Même problème, avec en prime l'apparence de l'officiel. Le texte d'un SMS se recopie.
Le retour du navigateur. Quand un client revient sur votre page de confirmation, cela signifie qu'il a cliqué, pas qu'il a payé. L'adresse de retour est manipulable par celui qui la visite : n'y attachez jamais de décision métier. Elle sert à afficher « merci, on vérifie », rien de plus.
La notification est un signal, pas une preuve
Vous configurez une adresse, nous vous appelons quand un paiement change d'état. C'est pratique, mais un appel entrant reste une donnée venue de l'extérieur. N'importe qui connaissant votre adresse peut la solliciter avec un corps JSON bien formé.
D'où deux gestes obligatoires, dans cet ordre : vérifier la signature, puis relire le statut auprès de l'API.
Vérifier la signature
Chaque livraison porte l'en-tête X-WalleoPay-Signature, de la forme :
t=1758268800,v1=6b8f1e...
t est l'horodatage Unix de la signature, v1 un HMAC SHA-256 calculé sur la chaîne timestamp.corps — l'horodatage, un point, puis le corps brut de la requête. La clé est votre secret de notification, celui qui commence par whsec_.
Trois détails qui font échouer la moitié des premières tentatives :
- Le corps doit être relu tel quel, octet pour octet. Si votre cadre applicatif a déjà décodé le JSON et que vous le ré-encodez pour calculer l'empreinte, l'ordre des clés, les espaces ou l'échappement des caractères accentués suffisent à changer le résultat.
- L'horodatage fait partie du message signé. Signer le corps seul ne donnera jamais la bonne valeur.
- Contrôlez l'écart de temps. Une signature valide reste valide pour toujours si vous ne regardez pas
t. Au-delà de cinq minutes, refusez : c'est la fenêtre de tolérance appliquée par la plateforme, et c'est ce qui empêche qu'un appel capté soit rejoué des heures plus tard.
En PHP
function verifierSignature(string $entete, string $corpsBrut, string $secret): bool
{
// « t=1758268800,v1=6b8f1e... »
parse_str(str_replace(',', '&', $entete), $parties);
$timestamp = (int) ($parties['t'] ?? 0);
$recu = (string) ($parties['v1'] ?? '');
if ($timestamp <= 0 || $recu === '') {
return false;
}
// Rejeu : au-delà de la tolérance, on refuse même si l'empreinte est bonne.
if (abs(time() - $timestamp) > 300) {
return false;
$this->mettreEnAvant();
}
$attendu = hash_hmac('sha256', $timestamp.'.'.$corpsBrut, $secret);
// Comparaison à temps constant : jamais ===.
return hash_equals($attendu, $recu);
}
$corps = file_get_contents('php://input'); // brut, surtout pas json_encode()
$ok = verifierSignature($_SERVER['HTTP_X_WALLEOPAY_SIGNATURE'] ?? '', $corps, getenv('WALLEOPAY_WEBHOOK_SECRET'));
http_response_code($ok ? 200 : 400);
En JavaScript
const crypto = require('crypto');
function verifierSignature(entete, corpsBrut, secret) {
const parties = Object.fromEntries(
String(entete || '').split(',').map((p) => p.split('='))
);
const timestamp = Number(parties.t);
const recu = parties.v1;
if (!timestamp || !recu) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;
const attendu = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${corpsBrut}`)
.digest('hex');
const a = Buffer.from(attendu, 'utf8');
const b = Buffer.from(recu, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express : il faut le corps brut, donc express.raw() et pas express.json().
app.post('/webhooks/walleopay', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifierSignature(req.get('X-WalleoPay-Signature'), req.body.toString('utf8'), process.env.WALLEOPAY_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const evenement = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // on accuse réception tout de suite
traiterEnArrierePlan(evenement);
});
Puis revérifier auprès de l'API
La signature prouve que l'appel vient bien de nous. Elle ne prouve pas que l'état qu'il décrit est encore d'actualité : les notifications peuvent arriver dans le désordre, être rejouées après un incident réseau, ou décrire un état déjà dépassé.
D'où la règle : le corps de la notification sert à savoir quel paiement regarder, pas à décider quoi faire.
$evenement = json_decode($corps, true);
$id = $evenement['data']['id'] ?? null; // « pay_01j… »
$reponse = $client->get("https://walleopay.com/api/v1/payments/{$id}", [
'headers' => ['Authorization' => 'Bearer '.getenv('WALLEOPAY_SECRET_KEY')],
]);
$paiement = json_decode((string) $reponse->getBody(), true);
if (($paiement['status'] ?? null) === 'succeeded') {
livrerLaCommande($paiement['reference'], $paiement['amount']);
}
Trois contrôles à ne pas sauter à ce moment-là :
- Le montant. Comparez
amountau montant attendu pour cette commande. Un paiement réussi de 500 francs sur une commande de 50 000 reste un paiement réussi. - La devise. Refusez tout ce qui n'est pas la devise de la commande.
- Votre référence. Le champ
referenceporte l'identifiant que vous avez envoyé à la création. C'est lui qui rattache le paiement à la bonne commande, pas un rapprochement par montant et par heure.
Livrer une seule fois
Un même événement peut vous parvenir plusieurs fois — c'est le prix d'une livraison fiable. Rendez donc votre traitement rejouable : avant de livrer, vérifiez que cette commande ne l'est pas déjà. Un champ paid_payment_id en base, posé une seule fois, suffit à rendre l'opération sûre quel que soit le nombre d'appels reçus.
Pensez aussi à répondre vite. Accusez réception d'abord, traitez ensuite : un serveur qui met trente secondes à répondre sera réessayé, ce qui multiplie les doublons.
La liste de contrôle
- Lire le corps brut, avant tout décodage.
- Vérifier la signature avec
hash_equalsou son équivalent à temps constant. - Refuser au-delà de cinq minutes d'écart.
- Répondre
200immédiatement. - Relire le statut via
GET /payments/{id}. - Contrôler le statut, le montant, la devise et votre référence.
- Livrer une seule fois, de façon rejouable.
- Ne jamais livrer sur une capture, un SMS ou une adresse de retour.
Ces huit points ne prennent pas une heure à écrire. Ils évitent la catégorie d'incident la plus pénible qui soit : celle où l'on découvre le problème en comptant l'argent.