Webhook sortant
Le canal nominal de l'issue d'un paiement. Secret, signature, rejeu.
Le webhook est le canal nominal par lequel vous apprenez ce qu'est devenu un
paiement : il vous pousse l'issue sans que vous ayez à demander, et pour les
corridors en redirection c'est le seul endroit d'où sort l'authorizationUrl.
Une notification perdue se rattrape par clé d'API — état courant, journal des tentatives, renvoi à la demande : voir Réconciliation. Configurez le webhook quand même : sans lui, vous interrogeriez l'API pour une issue qui vous serait autrement poussée en quelques secondes.
Deux prérequis, sinon rien n'est émis
- Un secret actif sur votre organisation.
POST /v1/webhook-secretsrenvoie une cléwhsec_…, affichée une seule fois. Sans secret actif, aucune notification n'est signée, donc aucune n'est émise. - Un
webhookUrl, par transaction ou par défaut sur l'organisation. Le premier se fournit dans le corps du pay-in ou du pay-out, et prime toujours. À défaut, az54 retombe sur l'URL de repli de l'organisation — mais la régler est un réglage de compte, pas une surface d'intégration : ni clé d'API, ni écran du tableau de bord ne l'atteignent aujourd'hui (cf. Clés d'API et webhooks). En pratique, une transaction postée sanswebhookUrlne notifiera rien tant que ce repli n'est pas réglé pour votre organisation.
Dans les deux cas, le pay-in réussit et répond 200 : le manque se lit dans le
journal des tentatives, sous SKIPPED_NO_SECRET ou SKIPPED_NO_URL, sur
GET /v1/transactions/by-id/{transactionId}
(Réconciliation).
Le repli d'organisation est validé à son enregistrement — même garde-fou
que celui qui protège l'émission plus bas (schéma https, résolution vers une
IP publique unicast) — pour refuser une cible injoignable tout de suite
plutôt que de le découvrir dans le journal des tentatives. Une valeur null
efface le repli.
Ce que vous recevez
Un POST en application/json sur votre webhookUrl, émis à chaque
changement de statut et à la mise à disposition d'une authorizationUrl.
{
"phoneNumber": "+2250700000000",
"amount": 100,
"currencyCode": "XOF",
"orderId": "order-3f9a1c72-0b8e-4d5a-9c31-7e2f6b4d8a10",
"transactionId": "8c4d1e07-5b2a-4f63-9d18-2ae7f0b6c934",
"status": "completed",
"signature": "5f2c…",
"_nonce": "9b1d4a0c7e6f2358",
"_timestamp": 1755600000000
}orderIdest votre identifiant : c'est la clé de rapprochement.transactionIdest l'identifiant az54 de la transaction — celui que prendGET /v1/transactions/by-id/{transactionId}, pas la référence du PSP.amountest en unité majeure decurrencyCode, la devise du corridor.failureCategory,failureReasonetfailureCodesont renseignés sur tout statut terminal en échec (failedoucancelled), pay-in comme pay-out — voir Causes d'échec pour le détail des canaux et le catalogue des 8 valeurs possibles.failureReasonetfailureCoderestent le détail brut du PSP, pas contractuels.- Un refus de prestataire ne produit pas de notification
failedintermédiaire : az54 peut rejouer l'opération chez un autre prestataire du même canal, à frais inchangés pour vous. Vous ne recevez qu'une seule notification terminale, celle de l'issue définitive. Sur un pay-out az54 rejoue sur plusieurs causes de refus ; sur un pay-in, uniquement quand le prestataire a déclaré son propre système indisponible (PROVIDER_UNAVAILABLE), auquel cas le payeur reçoit une seconde sollicitation — un nouveau push USSD ou une nouvelle page de paiement, sur le mêmeorder_id. nextAction.authorizationUrln'apparaît que pour un corridor en redirection.- Le corps ne porte pas les frais.
feeetnetAmountse lisent sur les deux lectures par clé d'API — voir Montants et devises.
Le détail des champs est dans la référence.
Vérifier la signature
signature est un HMAC-SHA512 hexadécimal minuscule, calculé avec votre
secret whsec_… sur la concaténation de tous les autres champs :
- Retirez la clé
signature. Gardez tous les autres champs,_nonceet_timestampcompris. - Parcourez les clés dans leur ordre d'apparition dans le corps reçu — pas par ordre alphabétique.
- Concaténez
clépuisvaleur, sans séparateur. Pour un objet imbriqué, descendez dedans en préfixant ses clés du chemin parent :nextAction.authorizationUrl. HMAC-SHA512(chaîne, votre_secret), en hexadécimal minuscule, comparé en temps constant.
Sur l'exemple ci-dessus, la chaîne signée est :
phoneNumber+2250700000000amount100currencyCodeXOForderIdorder-3f9a1c72-0b8e-4d5a-9c31-7e2f6b4d8a10transactionId8c4d1e07-5b2a-4f63-9d18-2ae7f0b6c934statuscompleted_nonce9b1d4a0c7e6f2358_timestamp1755600000000import { createHmac, timingSafeEqual } from "node:crypto";
function stringToSign(data, prefix = "") {
let out = "";
for (const [key, value] of Object.entries(data)) {
if (key === "signature" || value === undefined) continue;
out +=
value !== null && typeof value === "object"
? stringToSign(value, `${prefix}${key}.`)
: `${prefix}${key}${value}`;
}
return out;
}
export function isAuthentic(body, secret) {
const expected = createHmac("sha512", secret)
.update(stringToSign(body))
.digest("hex");
const received = Buffer.from(body.signature ?? "", "utf8");
const computed = Buffer.from(expected, "utf8");
return (
received.length === computed.length && timingSafeEqual(received, computed)
);
}Ce vérificateur parcourt le corps reçu, sans liste de champs codée en dur. C'est la seule forme qui survive à l'ajout d'un champ (Versionnement).
Les nombres entrent dans la chaîne tels qu'ils apparaissent dans le corps
reçu : 100, pas 100.0. Si votre langage reformate les nombres au décodage
JSON, lisez-les sur le corps brut — c'est la cause la plus fréquente d'une
signature qui ne correspond pas.
Deux limites à connaître. La chaîne signée n'a aucun séparateur : elle lie
la concaténation, pas la structure champ par champ. Et il n'y a aucune
protection anti-rejeu — _nonce entre dans la signature mais n'est vérifié
nulle part côté az54. Traitez donc une notification comme un déclencheur, pas
comme une autorité : la décision métier se prend sur le status, dont les
transitions sont monotones, et _timestamp (millisecondes
depuis l'epoch) vous permet d'ignorer une notification anormalement ancienne.
Rejeu automatique jusqu'à 6 tentatives
- Timeout 30 secondes par tentative. Répondez d'abord, traitez ensuite.
- Aucune redirection suivie. Un
301ou un302sur votre URL est un échec. - Jusqu'à 6 tentatives, espacées jusqu'à T+7 h. La chaîne s'arrête au
premier
2xx.
L'échelle exacte, le journal des tentatives et le renvoi à la demande sont dans Réconciliation.
Deux conséquences pour votre handler :
- Il doit être idempotent. Les notifications ne sont ni numérotées ni ordonnées, et une même issue peut vous être livrée plusieurs fois. Fiez-vous à la monotonie des statuts plutôt qu'à l'ordre d'arrivée.
- Ne dédupliquez pas sur
_nonceni sursignature. Chaque tentative de rejeu porte un_nonce, un_timestampet donc unesignaturenouveaux : deux tentatives du même événement ne se ressemblent pas. La paire (orderId,status) est la seule clé de déduplication stable.
Les contraintes de votre webhookUrl
- Schéma
httpsobligatoire.http://(et tout autre schéma) est refusé dès la requête de pay-in/pay-out, avec un400explicite. - 2 048 caractères au plus, comme l'URL par défaut de l'organisation.
Au-delà, la requête est refusée avec un
400. - L'hôte doit résoudre vers une IP publique unicast. Une IP privée,
link-local ou de bouclage — y compris déguisée en IPv6 — est refusée. Ce
contrôle est rejoué à chaque tentative, la résolution DNS pouvant changer
entre deux essais ; une URL qui le viole passe donc le
200du pay-in, et son refus se lit dans le journal sousBLOCKED_UNSAFE_URL. - Aucune redirection suivie (ci-dessus) : la cible validée doit servir votre endpoint elle-même.
Vérifiez votre endpoint avant de lancer un vrai paiement : il n'existe pas d'environnement de test.
Rotation du secret
Créer un secret n'invalide pas les précédents : plusieurs secrets actifs peuvent coexister. C'est le plus ancien secret actif de votre organisation qui signe, et il continue de signer tant qu'il n'est pas révoqué — créer un secret ne bascule donc pas la signature sous vos pieds.
La rotation est par conséquent : créer le nouveau secret, déployer un vérificateur qui accepte les deux, révoquer l'ancien. Dans cet ordre : entre une révocation et la création du remplaçant, il n'y a aucun secret actif, donc aucune notification émise — elles seront rejouées, mais vous perdez la fenêtre.
Les trois verbes de /v1/webhook-secrets — créer, lister, révoquer — sont
accessibles par clé d'API, ce qui rend la rotation automatisable. Leur détail est
dans la référence ; deux points valent d'être dits ici :
- le listing est trié du plus récent au plus ancien, donc le secret qui signe est le dernier de la liste ;
- révoquer le dernier secret actif coupe toutes vos notifications. En cas de fuite, créez d'abord, révoquez ensuite.
