Webhook sortant
La seule façon fiable de connaître l'issue d'un paiement. Secret, signature, et les échecs silencieux.
Avec une clé d'API, le webhook est le seul canal qui vous dit ce qu'est devenu
un paiement. GET /v1/transactions n'est pas accessible par clé d'API, et le
reçu public est plafonné trop bas pour servir de polling.
Pour les corridors en redirection, c'est aussi le seul endroit d'où sort
l'authorizationUrl.
Deux prérequis, tous deux silencieux s'ils manquent
- Un secret actif, appartenant à l'utilisateur qui crée la transaction.
POST /v1/webhook-secretsrenvoie une cléwhsec_…, affichée une seule fois. La recherche du secret à l'émission se fait par utilisateur, alors que le tableau de bord les liste par organisation : un secret créé par un collègue y apparaît actif sans pour autant signer vos notifications. Créez le secret avec le compte dont vous utilisez la clé d'API. - Un
webhookUrlsur chaque transaction. Il se fournit dans le corps du pay-in ou du pay-out. Il n'y a pas d'URL de webhook configurable au niveau du compte : une transaction postée sanswebhookUrlne notifiera jamais rien.
Dans les deux cas le pay-in réussit, répond 200, et vous n'apprendrez jamais
son issue.
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,
"orderId": "order-3f9a1c72-0b8e-4d5a-9c31-7e2f6b4d8a10",
"status": "completed",
"signature": "5f2c…",
"_nonce": "9b1d4a0c7e6f2358",
"_timestamp": 1755600000000
}orderIdest votre identifiant : c'est la clé de rapprochement.amountest en unité majeure de la devise du pay-in. Le corps ne porte pas la devise — retrouvez-la parorderId.failureCategoryetfailureReasonne sont renseignés que sur l'échec synchrone d'un pay-out. Sur un pay-in en échec — la voie normale, par callback du PSP — le corps portestatus: "failed"sans cause. Ne branchez pas votre logique dessus.nextAction.authorizationUrln'apparaît que pour un corridor en redirection.- Le corps ne porte ni frais, ni identifiant technique az54.
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+2250700000000amount100orderIdorder-3f9a1c72-0b8e-4d5a-9c31-7e2f6b4d8a10statuscompleted_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)
);
}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.
Une seule tentative, jamais rejouée
- Timeout 30 secondes. Répondez d'abord, traitez ensuite.
- Aucune redirection suivie. Un
301ou302sur votre URL est un échec. - Aucun retry, aucune file d'attente. Une réponse autre que
2xx, un timeout, un certificat invalide : la notification est perdue définitivement, et az54 ne conserve aucun historique des tentatives sortantes.
Il n'y a donc aucun rattrapage, ni automatique ni en libre-service : le
tableau de bord n'a pas de bouton de renvoi. Un manqué se lit sur le statut de
la transaction dans le tableau de bord, ou se fait réémettre par le support
az54, avec le transactionId renvoyé à la création.
Les notifications ne sont ni numérotées ni ordonnées : votre handler doit être idempotent, et se fier à la monotonie des statuts plutôt qu'à l'ordre d'arrivée.
Les contraintes de votre webhookUrl
Elles sont vérifiées au moment de l'émission, pas à la validation du pay-in.
Une URL qui les viole passe donc le 200 du pay-in sans un mot, et la
notification est abandonnée :
- Schéma
httpsobligatoire.http://est accepté par le schéma du pay-in et rejeté à l'émission. - 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.
- Aucune redirection suivie, une seule tentative (ci-dessus).
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, et la notification est signée avec l'un d'eux. Une rotation propre est donc : créer le nouveau secret, accepter les deux signatures le temps de la bascule, puis révoquer l'ancien.
La révocation se fait depuis le tableau de bord — elle n'est pas exposée par clé d'API. En cas de fuite, créez d'abord, révoquez ensuite : un secret révoqué n'est pas récupérable.
