Engenharia6 de agosto de 20269 min de leitura

Como receber webhooks no Cloudflare Workers, a partir do Worker que operamos

Sem o crypto do Node, com limite de CPU em milissegundos e um waitUntil que não é durável. O que muda quando o receptor é um Worker, na entrada do AnyHook.

SÍNCRONO · UM SÓ PRAZO, COMPARTILHADOStripeverificação + handler de IA45 so timeout da Stripe acaba aquinova tentativa → execução duplicada200 NA EDGE + ENTREGA ASSÍNCRONA · DOIS PRAZOSStripeedgearmazena + 200cerca de 1 sfilanovas tentativashandler45 s, tudo bemo relógio do provedor para na edge
O motivo para colocar um receptor no Workers é que confirmar o recebimento e gravar custa pouco, e tudo o que é caro pode acontecer em outro lugar.

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:crypto nã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.waitUntil estende 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.

waitUntil · O 200 É UMA PROMESSA QUE VOCÊ NÃO PODE CUMPRIRverificarresponder 200waitUntil(work)só na memóriareciclada → perdido, nunca reenviadoARMAZENE PRIMEIRO · O 200 É VERDADEIROverificargravação durávelresponder 200processartenta de novo
O único 200 que vale enviar é aquele que você pode sustentar com algo já gravado.

É 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.

Todos os posts6 de agosto de 2026 · 9 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.