Vérification des webhooks

Confirmez que l'avis a été envoyé par Bifrost avant de faire confiance au corps.

ᚱ
"Nous volons à travers les neuf royaumes transportant des nouvelles instantanées, messagers divins qui informent de chaque mouvement à travers les ponts de Bifrost."
Huginn & Muninn, Voix du Père de Tous

Headers Envoyés dans les Webhooks

Tous les webhooks sont envoyés avec des headers spéciaux pour validation et identification:

http
POST /your-webhook-url HTTP/1.1
Host: your-api.example.com
Content-Type: application/json
User-Agent: Bifrost/1.0
X-Bifrost-Webhook-Type: payment_processed
X-Bifrost-Timestamp: 1728138000
X-Bifrost-Delivery: 9f2b1c745ae34d1890c61e7d34b0af52
X-Bifrost-Signature: v1=6f3a...c81d
X-Bifrost-Version: 1.0

X-Bifrost-Signature, X-Bifrost-Timestamp et X-Bifrost-Delivery accompagnent chaque livraison. Ce sont eux qui prouvent l'origine du message.

Types de webhooks

Le champ event du corps indique ce qui s'est passé. Voici toutes les valeurs qu'il peut prendre :

eventDescription
payment_processedPayout terminé. data.status vaut success. X-Bifrost-Webhook-Type : payment_processed.
payment_failedLe payout n'a pas pu aboutir. data ne contient que status (failed), reference_id et internal_reference. X-Bifrost-Webhook-Type : payment_processed.
payment_detectedDépôt vu sur le réseau ; funds_available vaut false. X-Bifrost-Webhook-Type : payment_confirmed.
payment_confirmedDépôt final ; funds_available vaut true. X-Bifrost-Webhook-Type : payment_confirmed.
payment_reversedUne réorganisation a annulé le dépôt et le crédit a été retiré. data.status vaut reversed, et check-payin renvoie désormais le statut reversed. X-Bifrost-Webhook-Type : payment_confirmed.
fixed_address_pre_revoke · fixed_address_revokedCycle de vie de l'adresse fixe, décrit à la fin de cette page. X-Bifrost-Webhook-Type : fixed_address_lifecycle.

X-Bifrost-Webhook-Type ne nomme que la famille de l'avis : tout événement de dépôt, y compris payment_detected et payment_reversed, arrive avec payment_confirmed dans cet en-tête, et un payout échoué avec payment_processed. Décidez toujours selon le champ event du corps.

Livraison et renvois

Chaque avis est un POST avec un corps JSON vers la webhook_url que vous avez enregistrée. Ce qui compte comme livré, et ce qui se passe sinon :

  • Répondez avec n'importe quel statut 2xx en moins de 30 secondes (la connexion elle-même doit s'ouvrir en moins de 10 secondes). Tout autre statut, y compris 3xx, un timeout ou une erreur de connexion, compte comme un échec.
  • Les redirections ne sont pas suivies. Le certificat TLS est vérifié, HTTPS est obligatoire en production, et la connexion se fait en IPv4 vers l'adresse validée pour votre hôte.
  • Une livraison échouée est retentée environ 5 minutes plus tard, jusqu'à 10 tentatives au total. Après le dixième échec, l'avis est marqué failed et peut être renvoyé depuis le panneau.
  • Chaque tentative porte le même corps, un X-Bifrost-Delivery et un X-Bifrost-Timestamp nouveaux, et une signature calculée pour eux.
  • Le même avis peut donc vous parvenir plusieurs fois. Dédupliquez sur data.internal_reference avec event (pour les avis d'adresse fixe, wallet avec event).
  • Répondez d'abord et traitez ensuite : effectuer un travail lent avant de répondre est la façon habituelle de dépasser la limite de 30 secondes.

Le Sceau de Heimdall

Comment confirmer que le message vient bien du pont

L'adresse qui reçoit vos webhooks est publique : quiconque découvre l'URL peut lui envoyer une requête. Sans vérification, un POST falsifié annonçant un paiement approuvé est indiscernable d'un vrai, et il se traduit souvent par une marchandise livrée sans que l'argent soit entré.

La clé de signature

Chaque compte dispose d'une clé dédiée, générée automatiquement et jamais transmise. Nous utilisons notre copie pour signer chaque livraison ; vous récupérez la vôtre une fois et la conservez. Comme elle ne voyage pas avec le webhook, une fuite de journaux dans votre environnement ne livre pas la clé, et c'est ce qui donne à la signature sa valeur de preuve.

Obtenir la clé

La clé existe déjà sur votre compte et se trouve dans le panneau, dans Paramètres, à la section des webhooks. L'afficher requiert votre second facteur. Copiez la valeur sur votre serveur et conservez-la comme un mot de passe de base de données : dans une variable d'environnement ou un coffre à secrets, jamais dans le code versionné.

Le panneau permet aussi de remplacer la clé, et ce remplacement invalide immédiatement la précédente. Entre la génération de la nouvelle et la mise à jour de votre copie, les webhooks sont signés avec une clé que vous n'avez pas encore, et votre vérification échouera durant cet intervalle.

En-têtes de la signature

ChampDescription
X-Bifrost-Signaturev1= suivi du HMAC-SHA256 en hexadécimal.
X-Bifrost-TimestampHorodatage Unix du moment où le webhook a été émis.
X-Bifrost-Delivery32 caractères hexadécimaux minuscules, uniques pour cette tentative de livraison. Change à chaque renvoi.

La chaîne signée

Le HMAC n'est pas calculé sur le corps brut, mais sur cette chaîne, assemblée avec des sauts de ligne entre les champs :

text
v1
{X-Bifrost-Timestamp}
{X-Bifrost-Delivery}
SHA256_HEX(RAW_BODY)

L'horodatage et l'identifiant de livraison entrent volontairement dans le calcul. Si seul le corps était signé, ces deux en-têtes pourraient être réécrits par n'importe qui sans invalider la signature, et un webhook capturé aujourd'hui serait rejoué des mois plus tard en paraissant fraîchement émis.

Vérification

php
function verifyBifrostWebhook(string $rawBody, array $headers, string $key): bool
{
    $signature = $headers['X-Bifrost-Signature'] ?? '';
    $timestamp = $headers['X-Bifrost-Timestamp'] ?? '';
    $delivery  = $headers['X-Bifrost-Delivery'] ?? '';

    if ($signature === '' || $timestamp === '' || $delivery === '') {
        return false;
    }

    // Reject stale messages. Five minutes is a comfortable margin
    // for clock drift between servers.
    if (abs(time() - (int) $timestamp) > 300) {
        return false;
    }

    $canonical = implode("\n", [
        'v1',
        $timestamp,
        $delivery,
        hash('sha256', $rawBody),
    ]);

    $expected = 'v1=' . hash_hmac('sha256', $canonical, $key);

    // hash_equals, not ==: the comparison must take the same time
    // regardless of where the strings diverge.
    return hash_equals($expected, $signature);
}

Précautions

  • Utilisez le corps brut. Désérialiser le JSON puis le resérialiser modifie les espaces et l'ordre des clés, et l'empreinte ne correspond plus. Capturez les octets exactement tels qu'ils sont arrivés, avant toute analyse.
  • Comparez en temps constant. hash_equals, timingSafeEqual et compare_digest existent pour cela : une comparaison de chaînes ordinaire révèle, par le temps de réponse, combien de caractères initiaux sont corrects.
  • Gérez les renvois. Une livraison qui ne reçoit pas de 2xx est retentée, avec un X-Bifrost-Delivery différent à chaque fois. Le corps n'a pas d'identifiant d'événement : dédupliquez sur data.internal_reference avec event, pour ne pas traiter deux fois le même paiement.
  • HTTPS est obligatoire en production. Les URL http:// sont refusées aussi bien à l'enregistrement qu'à l'envoi.

Webhook sans signature

Si l'en-tête X-Bifrost-Signature est absent, ne traitez pas le message et contactez le support.

Cycle de vie de l'adresse fixe

Les adresses fixes qui ne reçoivent rien pendant longtemps sont reprises. Avant et au moment où cela se produit, un avis est envoyé à l'URL de webhook des adresses fixes configurée dans le panneau, pas à une URL par paiement ; sans cette URL, aucun avis n'est envoyé. Les adresses de type evm sont permanentes et ne reçoivent jamais ces événements. Le corps est plat, sans enveloppe data, et signé comme tout autre webhook.

eventDescription
fixed_address_pre_revokeAprès 120 jours sans transaction. revoke_at indique quand l'adresse sera révoquée (environ 30 jours plus tard) sauf si elle reçoit une transaction avant.
fixed_address_revokedAprès 150 jours sans transaction. L'adresse n'appartient plus à votre compte ; status vaut revoked et revoke_at vaut null.
json
{
  "event": "fixed_address_pre_revoke",
  "wallet": "TQ5n3n1Yd8k9QeXbUuG5mJb4Lw2Rr7Vx9P",
  "network": "Tron",
  "token": null,
  "provider": "internal",
  "customer_info": "customer_12345",
  "status": "STATUS_ASSIGNED",
  "last_transaction_at": "2026-05-01 10:00:00",
  "assigned_at": "2026-02-10 09:30:00",
  "revoke_at": "2026-09-28 10:00:00",
  "message": "Address inactive; it will be revoked soon if no transaction is received.",
  "timestamp": "2026-08-29T10:00:00-03:00"
}
ChampDescription
eventfixed_address_pre_revoke ou fixed_address_revoked
walletL'adresse fixe
network · tokenRéseau et token de l'adresse ; token peut être null
providerRéservé. Ne vous y fiez pas.
customer_infoLe customer_info fourni lors de la demande d'adresse, ou null
statusStatut de l'adresse au moment de l'avis (STATUS_ASSIGNED en pré-révocation), ou revoked
last_transaction_at · assigned_atDernière transaction reçue et date d'attribution ; l'inactivité est comptée depuis last_transaction_at, ou depuis assigned_at si l'adresse n'a jamais reçu de transaction
revoke_atDate de révocation prévue sur fixed_address_pre_revoke ; null sur fixed_address_revoked
messageExplication lisible, en anglais
timestampMoment où l'avis a été généré, en ISO 8601