# Vérification des webhooks

> Version Markdown de la documentation publique de l'API v1 de Bifrost Crypto, pour les agents d'IA et les générateurs de code. Même contenu que les pages HTML, généré à partir de la même source, sans navigation ni mise en forme.
>
> Version HTML: https://bifrostcrypto.com/fr/docs/webhooks
> Fichiers liés: [ai.md](https://bifrostcrypto.com/ai/fr.md) · [Postman](https://bifrostcrypto.com/postman-collection.json) · [OpenAPI](https://bifrostcrypto.com/openapi.json) · [llms.txt](https://bifrostcrypto.com/llms.txt)
> Autres langues: [Português](https://bifrostcrypto.com/ai/pt-BR/webhooks.md) · [English](https://bifrostcrypto.com/ai/en/webhooks.md) · [Español](https://bifrostcrypto.com/ai/es/webhooks.md)
> API v1 · dernière modification du contrat le 2026-10-10

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

## 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 :

| 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 :

```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**

```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);
}
```

**Node.js**

```javascript
const crypto = require('crypto');

function verifyBifrostWebhook(rawBody, headers, key) {
  const signature = headers['x-bifrost-signature'];
  const timestamp = headers['x-bifrost-timestamp'];
  const delivery = headers['x-bifrost-delivery'];

  if (!signature || !timestamp || !delivery) return false;

  // Reject stale messages.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const bodyHash = crypto.createHash('sha256').update(rawBody).digest('hex');
  const canonical = ['v1', timestamp, delivery, bodyHash].join('\n');
  const expected =
    'v1=' + crypto.createHmac('sha256', key).update(canonical).digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);

  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**Python**

```python
import hashlib
import hmac
import time


def verify_bifrost_webhook(raw_body: bytes, headers: dict, key: str) -> bool:
    signature = headers.get("X-Bifrost-Signature", "")
    timestamp = headers.get("X-Bifrost-Timestamp", "")
    delivery = headers.get("X-Bifrost-Delivery", "")

    if not signature or not timestamp or not delivery:
        return False

    # Reject stale messages.
    if abs(time.time() - int(timestamp)) > 300:
        return False

    body_hash = hashlib.sha256(raw_body).hexdigest()
    canonical = "\n".join(["v1", timestamp, delivery, body_hash])
    expected = "v1=" + hmac.new(
        key.encode(), canonical.encode(), hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(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. |

```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"
}
```

| 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 |
