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

  1. Un secret actif, appartenant à l'utilisateur qui crée la transaction. POST /v1/webhook-secrets renvoie 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.
  2. Un webhookUrl sur 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 sans webhookUrl ne 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
}
  • orderId est votre identifiant : c'est la clé de rapprochement.
  • amount est en unité majeure de la devise du pay-in. Le corps ne porte pas la devise — retrouvez-la par orderId.
  • failureCategory et failureReason ne 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 porte status: "failed" sans cause. Ne branchez pas votre logique dessus.
  • nextAction.authorizationUrl n'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 :

  1. Retirez la clé signature. Gardez tous les autres champs, _nonce et _timestamp compris.
  2. Parcourez les clés dans leur ordre d'apparition dans le corps reçu — pas par ordre alphabétique.
  3. Concaténez clé puis valeur, sans séparateur. Pour un objet imbriqué, descendez dedans en préfixant ses clés du chemin parent : nextAction.authorizationUrl.
  4. 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_timestamp1755600000000
import { 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 301 ou 302 sur 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 https obligatoire. 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.

On this page