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:
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.0X-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 :
| event | Description |
|---|---|
| payment_processed | Payout terminé. data.status vaut success. X-Bifrost-Webhook-Type : payment_processed. |
| payment_failed | Le payout n'a pas pu aboutir. data ne contient que status (failed), reference_id et internal_reference. X-Bifrost-Webhook-Type : payment_processed. |
| payment_detected | Dépôt vu sur le réseau ; funds_available vaut false. X-Bifrost-Webhook-Type : payment_confirmed. |
| payment_confirmed | Dépôt final ; funds_available vaut true. X-Bifrost-Webhook-Type : payment_confirmed. |
| payment_reversed | Une 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_revoked | Cycle 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
| Champ | Description |
|---|---|
| X-Bifrost-Signature | v1= suivi du HMAC-SHA256 en hexadécimal. |
| X-Bifrost-Timestamp | Horodatage Unix du moment où le webhook a été émis. |
| X-Bifrost-Delivery | 32 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 :
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
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.
| event | Description |
|---|---|
| fixed_address_pre_revoke | Aprè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_revoked | Après 150 jours sans transaction. L'adresse n'appartient plus à votre compte ; status vaut revoked et revoke_at vaut null. |
{
"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"
}| Champ | Description |
|---|---|
| event | fixed_address_pre_revoke ou fixed_address_revoked |
| wallet | L'adresse fixe |
| network · token | Réseau et token de l'adresse ; token peut être null |
| provider | Réservé. Ne vous y fiez pas. |
| customer_info | Le customer_info fourni lors de la demande d'adresse, ou null |
| status | Statut de l'adresse au moment de l'avis (STATUS_ASSIGNED en pré-révocation), ou revoked |
| last_transaction_at · assigned_at | Derniè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_at | Date de révocation prévue sur fixed_address_pre_revoke ; null sur fixed_address_revoked |
| message | Explication lisible, en anglais |
| timestamp | Moment où l'avis a été généré, en ISO 8601 |