A camada de entrada do AnyHook é um Cloudflare Worker. Todo webhook que o serviço recebe chega primeiro ali, tem a assinatura verificada, é gravado, e o remetente recebe um 200 em cerca de um segundo, a maior parte disso gasta numa ida e volta até a região da fila. Isso roda em produção há um bom tempo, e as partes difíceis não foram as que eu esperava.
Este texto é o complemento, do lado de quem recebe, de Como receber webhooks na Vercel sem perder nenhum. Os modos de falha são realmente diferentes, e a maioria vem do fato de o Workers não ser Node.
TL;DR
- O módulo
node:cryptonão vem disponível por padrão. A verificação de assinatura passa pela WebCrypto, que é assíncrona - Leia o corpo uma vez, como bytes, antes que qualquer coisa faça o parse dele, como em qualquer lugar
- Aplique seu próprio limite de tamanho de payload durante o streaming, porque ler um corpo grande para descobrir que ele é grande é a ordem errada
- O método
ctx.waitUntilestende a requisição além da resposta, mas não é durável, exatamente como o da Vercel - O limite de CPU é pequeno no plano Workers Free e separado do tempo de relógio. Esperar uma API lenta não o consome; um loop apertado sobre um payload grande consome
- Um Worker é um ótimo lugar para confirmar o recebimento e gravar, e um lugar ruim para sua lógica de negócio
A verificação de assinatura é assíncrona, e isso muda seu fluxo de controle
No Node, você escreve um verificador síncrono e o chama logo no início. Os métodos da WebCrypto retornam promises, então a mesma lógica vira assíncrona, e a tentação é fazer o await só depois de já ter feito o parse do corpo para rotear. Não faça isso. A verificação vem antes de qualquer coisa que confie no payload.
Este é o formato que nossa camada de entrada usa para descriptografar um segredo de provedor armazenado antes de verificar a assinatura com ele. Tudo é crypto.subtle:
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(envKey.padStart(32, "0").slice(0, 32)),
"AES-GCM",
false,
["decrypt"],
);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv, tagLength: 128 },
keyMaterial,
dataToDecrypt,
);
Dois detalhes que custam tempo se você os descobrir do jeito difícil.
O AES-GCM da WebCrypto espera a tag de autenticação anexada ao texto cifrado, enquanto a função createDecipheriv do Node recebe a tag separadamente, pelo método setAuthTag. Se você criptografou no Node e está descriptografando num Worker, precisa concatenar os dois você mesmo. Nada avisa isso; você só recebe um erro OperationError.
A WebCrypto não tem uma função timingSafeEqual padrão. Para verificar um HMAC, prefira o método crypto.subtle.verify, que faz a comparação em tempo constante por você, a calcular o digest e comparar strings.
Você pode ativar a flag nodejs_compat e ganhar um shim de node:crypto, e, para um HMAC simples, esse é um atalho razoável. Mas o shim é uma camada de compatibilidade sobre as mesmas primitivas, e programar direto contra a WebCrypto faz o código de verificação rodar sem mudanças também no Deno, no Bun e no navegador.
Leia o corpo como bytes e pare cedo se ele for grande demais
A regra do corpo bruto é universal: o HMAC cobre exatamente os bytes que chegaram, então não faça o parse de nada antes de verificar. Primeiro await request.text() ou await request.arrayBuffer().
O que é específico de um receptor na edge é que você é a porta de entrada para qualquer pessoa na internet que tenha sua URL, e colocar em buffer um corpo de tamanho arbitrário antes de decidir que não o queria é o jeito de pagar pelo tráfego dos outros. Nossa camada de entrada verifica primeiro o cabeçalho content-length e depois aplica o limite de novo durante o streaming, porque o valor de content-length é uma declaração do cliente, não um fato:
const contentLength = request.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > limitBytes) return null;
const reader = request.body?.getReader();
if (!reader) return new ArrayBuffer(0);
const chunks: Uint8Array[] = [];
let totalBytes = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > limitBytes) {
reader.cancel(); // stop pulling bytes we have decided to reject
return null;
}
chunks.push(value);
}
A chamada reader.cancel() é a parte que vale copiar. Sem ela, você termina de baixar um payload que já decidiu recusar.
A barreira mais barata vem primeiro
Um receptor na edge é um endpoint público, e a ordem das suas verificações é uma decisão de custo tanto quanto de segurança.
A nossa é: recusar requisições sem os cabeçalhos que o provedor alegado sempre envia, depois verificar a assinatura se houver um segredo configurado, depois contar o evento na cota. Nossa camada de entrada recusa uma requisição que diz ser um webhook da Stripe e não traz o cabeçalho Stripe-Signature antes de rodar qualquer criptografia e antes de contar qualquer coisa. Atacantes sem os cabeçalhos certos não consomem praticamente nada.
Essa ordem só funciona se a verificação barata for barata de verdade. Checar a presença de um cabeçalho é uma consulta num map. Verificar a assinatura é importar uma chave e calcular um HMAC. A cota é uma leitura no KV. Coloque as três nessa ordem e as operações caras só rodam no tráfego que já fez por merecer.
Por baixo de tudo isso ficam os rate limits, por IP e por app (uma URL de entrada para uma origem), além de um cache negativo curto para slugs que não existem, para que uma varredura em busca de endpoints válidos não vire uma conta para pagar.
waitUntil não é uma fila, de novo
O método ctx.waitUntil mantém o Worker vivo depois da resposta para que o trabalho em segundo plano possa terminar. Ele é realmente útil, e não é durabilidade.
Se o isolate for removido da memória ou a requisição for cancelada no meio do caminho, o trabalho se perde. Não há nova tentativa (retry), nem dead letter, nem registro. E você já respondeu 200, então o remetente considera o evento entregue e nunca mais vai enviá-lo. Esse evento agora não existe em lugar nenhum.
É a mesma armadilha do waitUntil da Vercel, e vale a mesma regra: grave antes de confirmar o recebimento. Grave o evento em algum lugar durável, responda 200 e depois processe.
Usamos o método waitUntil só para trabalho que podemos perder, como incrementar contadores. Se um contador de rejeições registrar três a menos durante uma remoção, ninguém sai prejudicado. Se o payload de um evento se perde, alguém sai prejudicado.
Ele também é curto. A Cloudflare cancela o trabalho do waitUntil 30 segundos depois da resposta, tempo dividido entre todas as promises que você passou para ele, e uma chamada a um modelo pode passar disso sozinha. Seu handler de webhook chama um LLM mostra o que usar no lugar em cada plataforma.
Para tudo o que precisa sobreviver, as opções são uma fila, um armazenamento durável ou os dois. Nossa camada de entrada publica numa fila gerenciada; se essa publicação falha, ela grava o evento em spool no R2 e, se isso também falha, estaciona o evento no Postgres. Se os três falham, respondemos 503 em vez de 200, para que a nova tentativa do próprio remetente vire a última linha de defesa. Vale dizer essa decisão com todas as letras: responder 200 a um evento que você não conseguiu armazenar em lugar nenhum é uma mentira que custa o evento.
Tempo de CPU não é tempo de relógio
O Workers cobra e limita o tempo de CPU, não o tempo que sua requisição passa esperando. Isso confunde quem vem do Lambda, onde os dois são a mesma coisa.
Esperar uma escrita no banco de dados ou uma API externa não consome CPU. Fazer o parse de um payload JSON de dois megabytes consome. Calcular o hash de um corpo grande consome. Um loop sobre dez mil elementos de um array consome.
Para um receptor de webhooks, isso é quase só notícia boa, porque o formato que você quer de qualquer jeito é quase todo I/O: ler bytes, verificar, gravar, responder. As operações que vão pegar você são as que as pessoas adicionam sem pensar, como uma chamada a JSON.parse num payload que você só precisava armazenar, ou uma regex sobre o corpo inteiro.
Se você só precisa rotear por um cabeçalho e gravar os bytes, não faça o parse do corpo. Armazene-o como bytes e deixe o consumidor que vem depois, que tem um limite de CPU de verdade, fazer o parse.
O que pertence à edge e o que não pertence
Depois de rodar isso em produção, a linha está mais clara do que no começo.
Bom num Worker: terminar o TLS perto do remetente, recusar lixo, verificar assinaturas, gravar o payload, publicar numa fila, responder 200 rápido. Tudo limitado por I/O, tudo barato, tudo sensível à latência de um jeito que realmente ganha por estar perto de quem chama.
Ruim num Worker: sua lógica de negócio. Qualquer coisa que precise de um pool de conexões de longa duração, uma árvore de dependências grande, CPU pesada ou o ecossistema do Node. Não porque seja impossível fazer funcionar, mas porque você estaria brigando com a plataforma sem nenhum ganho, e todo o sentido de colocar o receptor na edge é que a parte cara acontece em outro lugar, no próprio ritmo.
Essa divisão, confirmar o recebimento na edge e entregar de forma assíncrona, é a arquitetura sobre a qual o AnyHook foi construído, não um argumento que inventamos depois.
Uma armadilha de deploy que vale conhecer
Se você tem vários ambientes num único arquivo wrangler.toml, o comando wrangler deploy sem --env production publica o ambiente padrão. Seu Worker de produção continua servindo o código anterior e nada dá erro, porque o deploy realmente deu certo, só que não onde você queria.
Perdemos uma tarde com isso. Os segredos seguem o mesmo formato: wrangler secret put NAME --env production, ou você terá configurado o segredo num lugar que não está servindo tráfego.
Conclusão
Um Worker é um ótimo receptor de webhooks, desde que você aceite o que ele é: uma porta de entrada limitada por I/O, sem Node, sem durabilidade no waitUntil e com um limite de CPU que pune o parse de que você não precisava.
Leia os bytes antes que qualquer coisa faça o parse deles, cancele o stream quando um payload passar do seu limite, ordene as recusas baratas antes das caras e nunca responda 200 a algo que você não gravou de forma durável. Todo o resto fica para as etapas seguintes, onde há tempo.