Webhook verification

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

ᚱ
"We fly across the nine realms carrying instant news, divine messengers who inform of every movement across the Bifrost bridges."
Huginn & Muninn, Voices of the Allfather

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:

eventDescription
payment_processedPayout completed. data.status is success. X-Bifrost-Webhook-Type: payment_processed.
payment_failedPayout could not be completed. data carries only status (failed), reference_id and internal_reference. X-Bifrost-Webhook-Type: payment_processed.
payment_detectedDeposit seen on-chain; funds_available is false. X-Bifrost-Webhook-Type: payment_confirmed.
payment_confirmedDeposit final; funds_available is true. X-Bifrost-Webhook-Type: payment_confirmed.
payment_reversedA 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_revokedFixed-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

FieldDescription
X-Bifrost-Signaturev1= followed by the HMAC-SHA256 in hexadecimal.
X-Bifrost-TimestampUnix timestamp of when the webhook was issued.
X-Bifrost-Delivery32 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
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);
}

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.

eventDescription
fixed_address_pre_revokeAfter 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_revokedAfter 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"
}
FieldDescription
eventfixed_address_pre_revoke or fixed_address_revoked
walletThe fixed address
network · tokenNetwork and token of the address; token can be null
providerReserved. Do not rely on it.
customer_infoThe customer_info you gave when requesting the address, or null
statusAddress status at the time of the notice (STATUS_ASSIGNED on pre-revoke), or revoked
last_transaction_at · assigned_atLast 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_atExpected revocation date on fixed_address_pre_revoke; null on fixed_address_revoked
messageHuman-readable explanation in English
timestampWhen the notice was generated, in ISO 8601