Verificação de webhooks

Confirme que o aviso foi enviado pela Bifrost antes de confiar no corpo.

ᚱ
"Voamos pelos nove reinos carregando notícias instantâneas, mensageiros divinos que informam cada movimento através das pontes do Bifrost."
Huginn & Muninn, Vozes do Pai de Todos

Headers Enviados nos Webhooks

Todos os webhooks são enviados com headers especiais para validação e identificação:

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 e X-Bifrost-Delivery acompanham toda entrega. São eles que provam a origem da mensagem.

Tipos de webhook

O campo event do corpo diz o que aconteceu. Estes são todos os valores que ele pode ter:

eventDescrição
payment_processedPayout concluído. data.status vem success. X-Bifrost-Webhook-Type: payment_processed.
payment_failedPayout não pôde ser concluído. data traz só status (failed), reference_id e internal_reference. X-Bifrost-Webhook-Type: payment_processed.
payment_detectedDepósito visto na rede; funds_available vem false. X-Bifrost-Webhook-Type: payment_confirmed.
payment_confirmedDepósito final; funds_available vem true. X-Bifrost-Webhook-Type: payment_confirmed.
payment_reversedUma reorganização desfez o depósito e o crédito foi retirado. data.status é reversed, e o check-payin passa a devolver status reversed. X-Bifrost-Webhook-Type: payment_confirmed.
fixed_address_pre_revoke · fixed_address_revokedCiclo de vida do endereço fixo, descrito no fim desta página. X-Bifrost-Webhook-Type: fixed_address_lifecycle.

X-Bifrost-Webhook-Type só nomeia a família do aviso: todo evento de depósito, inclusive payment_detected e payment_reversed, chega com payment_confirmed nesse cabeçalho, e um payout que falhou chega com payment_processed. Decida sempre pelo campo event do corpo.

Entrega e retentativas

Cada aviso é um POST com corpo JSON para a webhook_url que você cadastrou. O que conta como entregue, e o que acontece quando não é:

  • Responda com qualquer status 2xx em até 30 segundos (a conexão em si precisa abrir em até 10 segundos). Qualquer outro status, inclusive 3xx, timeout ou erro de conexão, conta como falha.
  • Redirecionamentos não são seguidos. O certificado TLS é verificado, HTTPS é obrigatório em produção, e a conexão é feita por IPv4 ao endereço validado para o seu host.
  • Uma entrega que falhou é tentada de novo cerca de 5 minutos depois, até 10 tentativas no total. Depois da décima falha o aviso fica marcado como failed e pode ser reenviado pelo painel.
  • Toda tentativa leva o mesmo corpo, um X-Bifrost-Delivery e um X-Bifrost-Timestamp novos, e uma assinatura calculada para eles.
  • O mesmo aviso pode, portanto, chegar mais de uma vez. Deduplique por data.internal_reference junto com event (nos avisos de endereço fixo, wallet junto com event).
  • Responda primeiro e processe depois: fazer trabalho lento antes de responder é o caminho mais comum para estourar o limite de 30 segundos.

O Selo de Heimdall

Como confirmar que a mensagem veio mesmo da ponte

O endereço que recebe seus webhooks é público: qualquer um que descubra a URL pode enviar uma requisição para ela. Sem verificação, um POST forjado anunciando pagamento aprovado é indistinguível de um pagamento real, e costuma virar mercadoria liberada sem que o dinheiro tenha entrado.

A chave de assinatura

Cada conta tem uma chave dedicada, gerada automaticamente e nunca transmitida. Nós usamos a nossa cópia para assinar cada entrega; você busca a sua uma vez e guarda. Como ela não viaja junto com o webhook, um vazamento de logs no seu ambiente não entrega a chave, e é isso que faz a assinatura provar alguma coisa.

Obtendo a chave

A chave já existe na sua conta e fica no painel, em Configurações, na área de webhooks. Exibi-la pede o seu segundo fator. Copie o valor para o seu servidor e guarde-o como guardaria uma senha de banco de dados: em variável de ambiente ou cofre de segredos, nunca no código versionado.

O painel também permite trocar a chave, e a troca invalida a anterior de imediato. Entre gerar a nova e atualizar a sua cópia, os webhooks saem assinados com uma chave que você ainda não tem, e a sua verificação vai falhar nesse intervalo.

Cabeçalhos da assinatura

CampoDescrição
X-Bifrost-Signaturev1= seguido do HMAC-SHA256 em hexadecimal.
X-Bifrost-TimestampUnix timestamp de quando o webhook foi emitido.
X-Bifrost-Delivery32 caracteres hexadecimais minúsculos, únicos para esta tentativa de entrega. Muda a cada retentativa.

A string assinada

O HMAC não é calculado sobre o corpo cru, e sim sobre esta string, montada com quebras de linha entre os campos:

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

O timestamp e o identificador de entrega entram no cálculo de propósito. Se apenas o corpo fosse assinado, esses dois cabeçalhos poderiam ser reescritos por qualquer um sem invalidar a assinatura, e um webhook capturado hoje seria reenviado meses depois parecendo recém-emitido.

Verificando

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

Cuidados

  • Use o corpo cru. Desserializar o JSON e serializar de novo muda espaços em branco e ordem de chaves, e o hash deixa de bater. Capture os bytes exatamente como chegaram, antes de qualquer parsing.
  • Compare em tempo constante. hash_equals, timingSafeEqual e compare_digest existem para isso: uma comparação comum de strings vaza, pelo tempo de resposta, quantos caracteres iniciais estão certos.
  • Trate o reenvio. Uma entrega que não recebe 2xx é tentada de novo, com X-Bifrost-Delivery diferente a cada tentativa. O corpo não tem identificador de evento: deduplique por data.internal_reference junto com event, para não processar o mesmo pagamento duas vezes.
  • HTTPS é obrigatório em produção. URLs http:// são recusadas tanto no cadastro quanto no envio.

Webhook sem assinatura

Se o cabeçalho X-Bifrost-Signature não vier, não processe a mensagem e fale com o suporte.

Ciclo de vida do endereço fixo

Endereços fixos que ficam muito tempo sem receber nada são recolhidos. Antes e no momento em que isso acontece, um aviso é enviado para a URL de webhook de endereços fixos cadastrada no painel, não para uma URL de pagamento; sem essa URL nenhum aviso é enviado. Endereços do tipo evm são permanentes e nunca recebem esses eventos. O corpo é plano, sem o envelope data, e é assinado como todo webhook.

eventDescrição
fixed_address_pre_revokeDepois de 120 dias sem transação. revoke_at informa quando o endereço será revogado (cerca de 30 dias depois), a menos que receba uma transação antes.
fixed_address_revokedDepois de 150 dias sem transação. O endereço deixa de pertencer à sua conta; status vem revoked e revoke_at vem 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"
}
CampoDescrição
eventfixed_address_pre_revoke ou fixed_address_revoked
walletO endereço fixo
network · tokenRede e token do endereço; token pode ser null
providerReservado. Não dependa dele.
customer_infoO customer_info informado ao pedir o endereço, ou null
statusStatus do endereço no momento do aviso (STATUS_ASSIGNED na pré-revogação), ou revoked
last_transaction_at · assigned_atÚltima transação recebida e data de atribuição; a inatividade conta a partir de last_transaction_at, ou de assigned_at quando o endereço nunca recebeu transação
revoke_atData prevista da revogação em fixed_address_pre_revoke; null em fixed_address_revoked
messageExplicação legível, em inglês
timestampQuando o aviso foi gerado, em ISO 8601