# Webhook verification

> Markdown version of the public documentation of the Bifrost Crypto API v1, for AI agents and code generators. Same content as the HTML pages, generated from the same source, without navigation or styling.
>
> HTML version: https://bifrostcrypto.com/en/docs/webhooks
> Related files: [ai.md](https://bifrostcrypto.com/ai.md) · [Postman](https://bifrostcrypto.com/postman-collection.json) · [OpenAPI](https://bifrostcrypto.com/openapi.json) · [llms.txt](https://bifrostcrypto.com/llms.txt)
> Other languages: [Português](https://bifrostcrypto.com/ai/pt-BR/webhooks.md) · [Español](https://bifrostcrypto.com/ai/es/webhooks.md) · [Français](https://bifrostcrypto.com/ai/fr/webhooks.md)
> API v1 · contract last changed on 2026-10-10

Confirm that a notification was sent by Bifrost before you trust the body.

## Headers Sent in Webhooks

All webhooks are sent with special headers for validation and 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 and X-Bifrost-Delivery accompany every delivery. They are what proves where the message came from.

## Webhook types

The event field of the body says what happened. These are all the values it can take:

| event | Description |
| --- | --- |
| `payment_processed` | Payout completed. data.status is success. X-Bifrost-Webhook-Type: payment_processed. |
| `payment_failed` | Payout could not be completed. data carries only status (failed), reference_id and internal_reference. X-Bifrost-Webhook-Type: payment_processed. |
| `payment_detected` | Deposit seen on-chain; funds_available is false. X-Bifrost-Webhook-Type: payment_confirmed. |
| `payment_confirmed` | Deposit final; funds_available is true. X-Bifrost-Webhook-Type: payment_confirmed. |
| `payment_reversed` | A reorganisation undid the deposit and the credit was removed. data.status is reversed, and check-payin returns status reversed from then on. X-Bifrost-Webhook-Type: payment_confirmed. |
| `fixed_address_pre_revoke · fixed_address_revoked` | Fixed-address lifecycle, described at the end of this page. X-Bifrost-Webhook-Type: fixed_address_lifecycle. |

X-Bifrost-Webhook-Type only names the family of the notice: every deposit event, including payment_detected and payment_reversed, arrives with payment_confirmed in that header, and a failed payout with payment_processed. Always branch on the event field of the body.

## Delivery and retries

Each notice is a POST with a JSON body to the webhook_url you registered. What counts as delivered, and what happens when it is not:

- Answer with any 2xx status within 30 seconds (the connection itself must open within 10 seconds). Any other status, including 3xx, a timeout or a connection error counts as a failure.
- Redirects are not followed. The TLS certificate is verified, HTTPS is required in production, and the connection is made over IPv4 to the address validated for your host.
- A failed delivery is retried about 5 minutes later, up to 10 attempts in total. After the tenth failure the notice is marked failed and can be resent from the panel.
- Every attempt carries the same body, a new X-Bifrost-Delivery and X-Bifrost-Timestamp, and a signature computed for them.
- The same notice can therefore reach you more than once. Deduplicate on data.internal_reference together with event (for fixed-address notices, wallet together with event).
- Reply first and process afterwards: doing slow work before answering is the usual way to hit the 30-second limit.

## Heimdall's Seal

How to confirm the message really came across the bridge

The address receiving your webhooks is public: anyone who discovers the URL can send a request to it. Without verification, a forged POST announcing an approved payment is indistinguishable from a real one, and it usually turns into goods released without the money ever arriving.

### The signing key

Every account has a dedicated key, generated automatically and never transmitted. We use our copy to sign each delivery; you fetch yours once and store it. Because it does not travel with the webhook, a log leak in your environment does not hand over the key, and that is what makes the signature prove anything at all.

### Getting the key

The key already exists on your account and lives in the panel, under Settings, in the webhooks area. Displaying it requires your second factor. Copy the value to your server and store it as you would a database password: in an environment variable or a secrets vault, never in versioned code.

> The panel also lets you replace the key, and replacing it invalidates the previous one immediately. Between generating the new one and updating your copy, webhooks are signed with a key you do not have yet, and your verification will fail during that window.

### Signature headers

| Field | Description |
| --- | --- |
| `X-Bifrost-Signature` | v1= followed by the HMAC-SHA256 in hexadecimal. |
| `X-Bifrost-Timestamp` | Unix timestamp of when the webhook was issued. |
| `X-Bifrost-Delivery` | 32 lowercase hexadecimal characters, unique for this delivery attempt. It changes on every retry. |

### The signed string

The HMAC is not computed over the raw body, but over this string, joined with line breaks between the fields:

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

The timestamp and the delivery identifier are part of the calculation on purpose. Were only the body signed, those two headers could be rewritten by anyone without invalidating the signature, and a webhook captured today could be replayed months later looking freshly issued.

### Verifying

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

### Pitfalls

- Use the raw body. Deserialising the JSON and serialising it again changes whitespace and key order, and the hash stops matching. Capture the bytes exactly as they arrived, before any parsing.
- Compare in constant time. hash_equals, timingSafeEqual and compare_digest exist for this: an ordinary string comparison leaks, through response time, how many leading characters are correct.
- Handle retries. A delivery that does not receive a 2xx is attempted again, with a different X-Bifrost-Delivery each time. The body has no event identifier: deduplicate on data.internal_reference together with event, so the same payment is not processed twice.
- HTTPS is mandatory in production. http:// URLs are refused both when registering and when sending.

### Webhook with no signature

If no X-Bifrost-Signature header arrives, do not process the message and contact support.

## Fixed-address lifecycle

Fixed addresses that receive nothing for a long time are taken back. Before and when that happens, a notice is sent to the fixed-address webhook URL set in the panel, not to a per-payment URL; without that URL no notice is sent. Addresses of type evm are permanent and never receive these events. The body is flat, with no data wrapper, and is signed like every other webhook.

| event | Description |
| --- | --- |
| `fixed_address_pre_revoke` | After 120 days without a transaction. revoke_at says when the address will be revoked (about 30 days later) unless it receives a transaction first. |
| `fixed_address_revoked` | After 150 days without a transaction. The address no longer belongs to your account; status is revoked and revoke_at is 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"
}
```

| Field | Description |
| --- | --- |
| `event` | fixed_address_pre_revoke or fixed_address_revoked |
| `wallet` | The fixed address |
| `network · token` | Network and token of the address; token can be null |
| `provider` | Reserved. Do not rely on it. |
| `customer_info` | The customer_info you gave when requesting the address, or null |
| `status` | Address status at the time of the notice (STATUS_ASSIGNED on pre-revoke), or revoked |
| `last_transaction_at · assigned_at` | Last transaction received and assignment date; inactivity is counted from last_transaction_at, or from assigned_at when the address has never received a transaction |
| `revoke_at` | Expected revocation date on fixed_address_pre_revoke; null on fixed_address_revoked |
| `message` | Human-readable explanation in English |
| `timestamp` | When the notice was generated, in ISO 8601 |
