Engenharia30 de julho de 20268 min de leitura

Um log de webhooks com adulteração detectável: cadeias de hashes e uma âncora pública

Num banco mutável, um log de webhooks é alegação, não prova. Como o log do AnyHook ficou verificável: cadeias por app, selo diário, âncora pública.

CADA CAMADA COMBINA A DE BAIXOfolha 1folha 2folha 3folha 4raiz do appuma por diaâncorarepositório públiconão temos credenciais de escritaaltere ou apague um evento antigo e todas as raízes seguintes mudamo limite, dito sem rodeios: adulterar algo dentro do dia atual, ainda não selado, não é detectável
Um log que o serviço não consegue reescrever em silêncio, ancorado num lugar para o qual ele não tem credenciais.

Todo serviço de webhooks mostra um log de entregas. A parte desconfortável é que esse log é uma linha no Postgres de alguém, e qualquer pessoa com acesso ao banco de dados, incluindo o fornecedor, pode alterá-lo com UPDATE. Altere um payload, retroceda um timestamp ou exclua uma linha inteira, e não sobra rastro. Quando uma disputa depende de "o que exatamente a Stripe enviou para você às 14:32", um log mutável não é prova. É uma alegação, sustentada apenas pela confiança no fornecedor.

Os frameworks de auditoria dizem isso com todas as letras: logs que podem ter sido adulterados têm valor probatório reduzido. Por isso, tornamos o log de eventos do AnyHook um log com adulteração detectável. Este post cobre o design inteiro, incluindo as partes que foram mais difíceis do que esperávamos e o limite que aceitamos de propósito.

TL;DR

  • Cada evento registra, no momento da escrita, um SHA-256 do seu corpo em texto puro
  • Um job diário encadeia os eventos de cada app: altere ou exclua qualquer evento passado e todas as raízes seguintes mudam
  • As raízes dos apps de cada dia se combinam numa única âncora pública, encadeada na do dia anterior
  • As âncoras são puxadas para um repositório público no GitHub por um workflow para o qual o serviço do AnyHook não tem nenhuma credencial. Um servidor invadido não consegue mexer no registro, e reescrever o histórico dele ficaria visível para qualquer pessoa com um clone
  • O limite, dito sem rodeios: uma adulteração dentro do dia atual, ainda não selado, não é detectável. O caminho de evolução existe; ninguém precisou dele ainda

A cadeia

Três camadas, todas SHA-256 puro.

Uma folha por evento:

leaf = sha256("{id}|{created_at}|{body_hash}")

body_hash é calculado a partir do corpo em texto puro, antes da criptografia. Isso é proposital: os corpos armazenados são criptografados com AES-256-GCM e um IV aleatório, então o texto cifrado não é reproduzível. Calcular o hash do texto puro significa que qualquer pessoa com o payload que recebeu consegue recalcular a folha. A verificação nunca precisa da nossa chave de criptografia.

A raiz do app é um fold sobre o dia do app, ordenado por (created_at, id), com a raiz anterior desse app como semente:

h = prev_root            # "" at genesis
for leaf in leaves:
    h = sha256(h + "|" + leaf)

Editar o evento #5 muda todos os hashes depois dele; excluir um encurta o fold; inserir um com data retroativa desloca tudo. Os três produzem uma raiz diferente.

A âncora do dia faz o fold de cada par (app_id, root), ordenado pelo id do app, com esta semente:

h = sha256(prev_anchor + "|" + day)

Esse day na semente importa mais do que parece. Nossa primeira versão usava só prev_anchor como semente, e isso fazia um dia com zero eventos produzir uma âncora idêntica à do dia anterior, e "dois dias tranquilos" ficava indistinguível de "um dia removido em silêncio da linha do tempo". Incluir a data no fold torna a âncora de cada dia distinta, então uma lacuna é, ela mesma, uma prova.

Por que cadeias por app, e não uma cadeia global

Uma cadeia global seria mais simples. Também significaria que verificar seu evento exige as folhas de todos os outros clientes, o que vaza para você os IDs de evento e os timestamps deles. Cadeias por app permitem que um cliente verifique o próprio histórico de forma independente, e a âncora do dia só precisa dos pares (app_id, root), que não dão acesso a nada.

Por que o selo roda uma vez por dia, fora do caminho de escrita

O design óbvio calcula a cadeia no insert. Não fizemos assim, porque uma cadeia de hashes global serializa as escritas (todo insert precisa esperar o hash do anterior), e o caminho de ingestão é exatamente onde um relay de webhooks (um serviço que recebe, guarda e encaminha seus webhooks) não pode ter um gargalo.

Em vez disso, um cron sela o dia UTC anterior uma única vez. Ele nunca é recalculado: quando a retenção apaga eventos antigos, refazer o fold produziria uma raiz diferente, e trocar em silêncio uma âncora publicada é exatamente a adulteração que este sistema existe para pegar.

O custo é uma janela. Edições feitas dentro do dia atual, antes do selo, não são detectáveis. Para provas de disputas medidas em semanas ou meses, uma janela de selagem de 24 horas é irrelevante. Se algum cliente precisar de garantias na mesma hora, a evolução são cadeias ao vivo por app: cada app serializa de forma independente, então os apps continuam escrevendo em paralelo. Escrevemos o comentário que nomeia esse limite e lançamos a versão simples.

A âncora vem por pull, não por push

A cadeia sozinha não prova nada, porque quem controla o banco de dados pode recalcular tudo. A âncora precisa ficar num lugar onde nossos servidores não conseguem escrever e onde uma edição retroativa ficaria visível. Nosso primeiro rascunho fazia o job de selagem enviar cada âncora para o GitHub com um token armazenado. Depois, invertemos isso.

O repositório de âncoras roda uma GitHub Action agendada própria, que consulta nossa API pública e faz commit do que vê. O serviço do AnyHook não tem nenhuma credencial para esse repositório. O workflow também se recusa a reescrever o histórico: ele busca de novo a janela inteira de âncoras a cada execução e, se uma âncora que já está em um commit divergir do que a API devolve agora, o job falha de forma ruidosa em vez de fazer commit por cima.

Uma âncora que o serviço não consegue escrever vale estritamente mais do que uma que ele consegue. Inverter o fluxo também eliminou trinta linhas de código de manipulação de token, que é o tipo de simplificação que dá para manter.

O QUE UM TERCEIRO PRECISA PARA CONFERIR A AFIRMAÇÃOo payloadque você recebeusha256folhacombinaraizâncorao body_hash é calculado antes da criptografia, então nada aqui precisa da nossa chaveshasum e um git clone são todo o ferramental
A verificação nunca precisa de nada que esteja conosco. É para isso que calculamos o hash do corpo em texto puro.

Como verificar com nada além de um shell

Tudo acima é reproduzível a partir da API pública. O dia da gênese, conferido à mão:

$ printf '%s' "|2026-07-27" | shasum -a 256
f27f9546bc0e021a051d8c469539406b50dded3d21a36a9331095632852413d3

Essa é a âncora publicada para 2026-07-27 (zero eventos e uma gênese vazia, então o fold inteiro é um único hash da semente). O dia dois incorpora esse valor no fold, e assim por diante. GET /api/v1/anchors?day=YYYY-MM-DD devolve os pares (app_id, root) de que qualquer auditor precisa para recalcular um dia sem conta, sem chave e sem a nossa cooperação.

Limites, com honestidade

  • O dia atual não está selado, como explicado acima. Esse é o preço de um caminho de escrita não serializado
  • A retenção limita a prova individual. Quando um evento é apagado pela retenção do plano, a folha dele não pode mais ser recalculada. A raiz do dia continua registrando a contagem e a ordem, mas provar um payload específico exige que o evento ainda exista. Uma retenção mais longa compra, literalmente, mais tempo de prova
  • Os registros começam no dia em que o recurso foi lançado, com um buraco: o cron do selo só conseguiu se autenticar em 2026-08-01, então os dias de 07-29 a 07-31 nunca foram selados. A cadeia pública vai de 07-28 direto para 08-01, e selar esses dias agora registraria o que o banco de dados diz hoje, que é exatamente o que um selo existe para impedir. Eventos gravados antes de existir o hash do corpo são registrados só pelo id e pelo timestamp. Uma cadeia não consegue cobrir retroativamente um histórico que ela não estava medindo
  • O repositório de âncoras fica na conta do GitHub do fundador. O serviço não consegue escrever nele; o dono consegue. O que torna cara uma reescrita silenciosa é que um histórico alterado com force push aparece para qualquer pessoa com um clone, e o workflow falha quando uma âncora já registrada em commit muda

Nada disso exige blockchain. O Git já é uma cadeia de hashes, o GitHub já registra o horário dos commits, e o Certificate Transparency provou o padrão anos atrás. A única decisão nova aqui é manter fora das mãos do serviço a caneta que escreve o registro público.

Todos os posts30 de julho de 2026 · 8 min

Pare de perder webhooks.

Troque uma URL. Tenha novas tentativas, um log de eventos e replay com um clique. Ou veja antes: crie uma conta, rode a simulação de queda de 30 segundos e veja um evento sobreviver ao seu servidor fora do ar. Não precisa de servidor.