Engenharia3 de agosto de 20269 min de leitura

Como receber webhooks na Vercel sem perder nenhum

Assinatura que falha sem motivo, waitUntil que some, cold start que consome o prazo de resposta: o que dá errado com webhooks em serverless e o que fazer.

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
A diferença entre um 200 que é verdade e um 200 que é esperança.

Em serverless, handlers de webhook falham de um punhado de maneiras específicas e repetíveis, e quase nenhuma delas parece o bug que realmente é. A verificação de assinatura falha com um payload comprovadamente correto. O trabalho deixado para depois da resposta nunca roda. Um handler que passa em todos os testes locais dá timeout só em produção, só sob carga.

Nada disso é a Vercel complicando as coisas. É o que acontece quando um modelo de execução limitado a cada requisição encontra um protocolo que pressupõe um servidor de longa duração. Estes são os cinco problemas que mais aparecem, na ordem em que você vai topar com eles.

TL;DR

  • await req.text() antes que qualquer coisa faça o parse do corpo, ou toda verificação de assinatura falha
  • Não recorra a runtime = "edge". Node.js no Fluid Compute é o padrão certo
  • waitUntil estende a função além da resposta, mas não é durável. Não é uma fila
  • O cold start consome o timeout do provedor, e a Shopify só dá 5 segundos
  • O prazo real do seu handler é o do provedor mais rigoroso, não o da Vercel

1. O problema do corpo bruto

É o primeiro em que todo mundo esbarra, e a mensagem de erro não ajuda em nada, porque o payload parece perfeito nos logs.

Todo provedor assina exatamente os bytes que enviou. A Stripe calcula o HMAC do corpo bruto com um prefixo de timestamp, a Shopify calcula o HMAC do corpo bruto, o GitHub calcula o HMAC do corpo bruto. Se qualquer coisa entre a rede e sua chamada de verificação fizer o parse do JSON e serializá-lo de novo, a ordem das chaves ou um espaço em branco muda um byte e o HMAC deixa de bater. Os dados são idênticos, mas a assinatura não.

O HMAC É SOBRE BYTES, NÃO SOBRE SIGNIFICADObytes recebidos{"id":"evt_1","n":2}req.text()hmacconfereparseado + serializado{ "n": 2, "id": "evt_1" }req.json()hmacnão conferemesmos dadosmesmos dados. outra ordem de chaves,outros espaços em branco, e o payloadparece perfeito nos seus logs.

No App Router, leia o texto primeiro e faça o parse a partir da string que você já tem:

// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const raw = await req.text();                     // exact bytes, before any parsing
  const sig = req.headers.get("stripe-signature");
  if (!sig) return new Response("missing signature", { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      raw, sig, process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response("invalid signature", { status: 400 });   // no details to the caller
  }

  await queue.publish(event);
  return new Response(null, { status: 200 });
}

Duas coisas para não confundir.

Nunca chame await req.json() primeiro. Depois disso, não há como recuperar os bytes originais: o stream já foi consumido, e o que você serializaria de novo não é o que foi assinado.

No Pages Router, você precisa desativar o body parser explicitamente. Se você ainda está em pages/api, defina export const config = { api: { bodyParser: false } } e leia o stream bruto você mesmo. A maioria dos relatos de "funciona localmente, mas não depois do deploy" vem da falta de alguma versão dessa linha.

Para os formatos de assinatura de cada provedor (o esquema t=/v1= da Stripe, o prefixo sha256= do GitHub, o base64 da Shopify), veja Como verificar assinaturas de webhook da Stripe, do GitHub e da Shopify. E use a função de verificação do SDK do provedor quando ela existir, em vez de escrever a sua: ela faz a comparação em tempo constante e a tolerância de timestamp por você, e as duas são fáceis de errar de um jeito sutil.

2. Não coloque handlers de webhook no runtime edge

Existe um instinto persistente de que um receptor de webhooks deve usar runtime = "edge" porque precisa ser rápido. É um conselho ultrapassado.

O padrão da Vercel é Node.js no Fluid Compute, que reaproveita instâncias entre requisições simultâneas e já fechou boa parte da diferença de cold start que tornava a edge atraente. Enquanto isso, o runtime edge tira de você coisas concretas de que um handler de webhook precisa: o módulo node:crypto completo, as APIs do Node que a maioria dos SDKs de provedores pressupõe e janelas de execução mais longas. Respostas em streaming, o outro motivo que as pessoas citam, funcionam bem no Node.js, e já faz tempo.

A falha concreta: um SDK de provedor que chama crypto.createHmac lança um erro na edge, então você escreve a verificação na mão com WebCrypto, e é nessa versão feita na mão que a comparação em tempo constante desaparece sem ninguém perceber. Você trocou uma garantia de segurança real por uma diferença de cold start que já quase sumiu.

Fique no padrão.

Se você já está diante de um handler que funciona localmente e falha em produção, Como depurar webhooks com falha em produção traz a árvore de decisão para descobrir qual camada está engolindo a requisição.

3. waitUntil não é uma fila

Na Vercel, a solução natural para "responda rápido, trabalhe depois" é a função waitUntil:

import { waitUntil } from "@vercel/functions";

export async function POST(req: Request) {
  const event = await verify(req);
  waitUntil(syncToSalesforce(event));      // continues after the response
  return new Response(null, { status: 200 });
}

Isso é útil de verdade, e de verdade não é durabilidade. A diferença importa mais do que parece:

  • Se a instância for reciclada ou cair no meio do caminho, o trabalho some. Não há nova tentativa (retry), nem dead-letter, nem registro
  • Você já respondeu 200 ao provedor. Ele nunca vai reenviar. Agora esse evento não existe em lugar nenhum
  • Falhas dentro de waitUntil não fazem a requisição falhar, então, a menos que você as capture e reporte explicitamente, elas são invisíveis

É o pior tipo de falha: rara, silenciosa e impossível de reconstruir depois, porque a única cópia do evento estava na memória.

waitUntil é a ferramenta certa para trabalho fire-and-forget que você pode se dar ao luxo de perder: pings de analytics, aquecimento de cache, uma notificação no Slack. É a ferramenta errada para qualquer coisa cuja falta um cliente notaria.

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

A regra que evita problemas é persistir antes de confirmar o recebimento. Grave o evento no Postgres, no Redis ou numa fila de verdade, depois responda 200, e só então processe. Agora o 200 diz a verdade, porque você tem mesmo o evento, e o processamento pode falhar e ser tentado de novo quantas vezes precisar.

export async function POST(req: Request) {
  const event = await verify(req);
  await db.insert(events).values({ id: event.id, payload: event }); // durable
  waitUntil(processNow(event.id));   // fast path; a cron sweeps anything left behind
  return new Response(null, { status: 200 });
}

O caminho rápido cuida do caso normal. A varredura cuida da instância que morreu. Nenhum dos dois perde o evento.

4. O cold start corre no relógio do provedor

O limite de execução padrão da Vercel é de 300 segundos, o que dá a impressão de que timeout deixa de ser problema. Não deixa, porque quem está cronometrando você não é a Vercel. Quem cronometra é quem está chamando você, e o prazo dele é bem mais curto:

ProvedorPrazo de resposta
Slack, Discord3 segundos
Shopify5 segundos
GitHub10 segundos
Twilio15 segundos
Stripenão publicado

O timeout real do seu handler é o do provedor mais rigoroso de quem você recebe, então vale conhecer todos os prazos a que você está exposto, e não só o que você lembra. Nossa referência de provedores de webhook lista todos. Contra os cinco segundos da Shopify, um cold start de 2 segundos já gastou 40% do seu prazo antes de a primeira linha rodar. O verificador de timeout cronometra duas requisições para sua rota, então a fria aparece ao lado da quente, e, se a parte lenta for uma chamada ao modelo, Seu handler de webhook chama um LLM explica a função after() e até onde ela vai.

O que ajuda de verdade:

  • Mantenha pequeno o grafo de imports da rota. Uma rota de webhook que só verifica e enfileira não deveria importar, por tabela, seu ORM, seu SDK de admin e seu cliente de e-mail. Cada um deles é interpretado no cold start
  • Importe o que é pesado sob demanda, dentro do trecho de código que precisa disso, não no escopo do módulo
  • Defina um valor baixo de maxDuration nas rotas de webhook. export const maxDuration = 10 não deixa nada mais rápido, mas transforma um handler descontrolado numa falha rápida que o provedor vai tentar de novo, em vez de um travamento de 300 segundos que queima computação e não aproxima você da solução

5. Às 3h da manhã, a concorrência não é sua amiga

Serverless escala horizontalmente, o que é exatamente o que você quer durante uma promoção relâmpago e exatamente o que quebra a premissa sob a qual a maior parte do código de idempotência é escrita.

Cem instâncias simultâneas significam cem handlers simultâneos, cada um abrindo conexões com o banco de dados. O Postgres tem um limite de conexões, e ele é mais baixo do que você imagina. É para isso que serve o pool de conexões da plataforma, e é sem ele que um pico de tráfego vira too many connections em toda requisição, inclusive nas de usuários reais.

O caso mais sutil: duas novas tentativas do mesmo evento podem cair em duas instâncias no mesmo milissegundo. Uma lógica de idempotência que lê e depois escreve tem aí uma condição de corrida que nenhum teste sequencial vai pegar. Quem precisa fazer esse trabalho é uma restrição de unicidade, não uma checagem com if:

// Racy: both instances read "not processed", both proceed
if (await db.hasProcessed(event.id)) return ok();
await doWork(event);

// Safe: the database arbitrates
const { rowCount } = await db.query(
  `INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING`,
  [event.id],
);
if (rowCount === 0) return ok();
await doWork(event);

Mais sobre isso, incluindo entrega fora de ordem, em Você recebeu o mesmo webhook duas vezes.

Onde o AnyHook entra

Todos os itens acima, exceto o do corpo bruto, são no fundo o mesmo problema: um runtime limitado a cada requisição está sendo cobrado por uma promessa de durabilidade que não consegue cumprir sozinho.

O AnyHook tira essa promessa de dentro da função. Os provedores fazem POST para in.anyhook.net/you/app, e:

  • O 200 volta em cerca de um segundo, de um worker na edge cuja única tarefa é persistir o evento e confirmar o recebimento. Os cinco segundos da Shopify deixam de ser sua restrição
  • Armazenamos o evento de forma durável, com criptografia em repouso, antes de confirmar o recebimento, então "a instância morreu" vira uma nova tentativa, e não uma perda
  • Tentamos de novo a entrega para sua função na Vercel em 5 minutos, 30 minutos, 3 horas, 18 horas e depois uma vez por dia, de 3 a 10 tentativas conforme o plano, e o que ainda falhar fica no log, disponível para replay (um novo envio de um evento armazenado, feito por você)
  • Um único cabeçalho AnyHook-Signature substitui os esquemas de verificação de cada provedor e é assinado de novo a cada tentativa, com um timestamp novo, para que as novas tentativas não estourem a janela de tolerância, como acontece com as assinaturas dos provedores encaminhadas
  • O replay funciona por intervalo de tempo, para quando uma janela de deploy engoliu uma hora de eventos

Seu handler ainda precisa ser idempotente. Nada elimina isso. Mas ele não precisa mais ser durável, rápido e estar sempre quente ao mesmo tempo.

Para ver funcionando antes de se cadastrar em qualquer coisa, um comando devolve uma URL de entrada ativa, sem conta:

curl -X POST https://anyhook.net/api/v1/quickstart

A resposta traz o campo inbound_url, para apontar um provedor, o campo api_key, para ler o log de eventos pela API, e o campo claim_url. O endpoint é temporário: ele e seus eventos são excluídos depois de 7 dias, a menos que você abra a URL de vínculo e entre com sua conta, o que mantém a mesma URL.

Conclusão

Leia o corpo bruto primeiro, fique no runtime Node.js e trate waitUntil como melhor esforço, não como fila. Se for para levar uma única ideia estrutural daqui, que seja esta: nunca responda 200 a um evento que você não gravou de forma durável. Todo o resto desta lista é problema de desempenho. Esse é um problema de perda de dados, e é o único que você não consegue depurar depois.

A versão desta lista para Workers é tão diferente que escrevemos um texto separado: Como receber webhooks no Cloudflare Workers.

Todos os posts3 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.