Webhooks e operações assíncronas

TL;DR

Nem toda resposta cabe num request-response síncrono. Quando uma operação leva mais que alguns segundos (exportar um relatório, processar um pagamento em lote, gerar um vídeo), o servidor não pode segurar a conexão HTTP aberta esperando — ele responde 202 Accepted de imediato e devolve um identificador de acompanhamento; o cliente então puxa o resultado periodicamente (polling) ou o servidor empurra o resultado quando fica pronto, via webhook. Um webhook é o inverso de uma API normal: em vez de o cliente chamar você, você vira o cliente e chama o sistema de quem contratou a integração — o que significa herdar, de graça, todos os problemas de confiabilidade de um cliente HTTP real (timeout, endpoint fora do ar, resposta lenta) só que agora do lado de quem estava acostumado a ser o servidor. Isso exige assinatura criptográfica pra provar autenticidade (HMAC com timestamp, contra replay), retry com backoff exponencial pra sobreviver a indisponibilidade momentânea do consumidor, deduplicação via ID do evento porque o mesmo evento pode chegar mais de uma vez, e um plano B explícito (dead letter + replay manual) pra quando tudo isso falha mesmo assim. Um terceiro padrão, bulk operations, resolve o problema simétrico — processar N itens numa única chamada — com a mesma pergunta de fundo por trás: o que acontece quando parte do lote falha e parte funciona? Os três padrões compartilham uma tensão comum: o contrato síncrono promete uma resposta imediata que a operação de fato não tem, e cada padrão é uma forma diferente de administrar esse descompasso sem mentir para o cliente sobre o estado real das coisas.

Uma healthtech de médio porte processa reembolsos de plano de saúde via um sistema de pagamento de terceiros — o fluxo é: a clínica submete o reembolso pela API da healthtech, a healthtech repassa pro provedor de pagamento, e quando o provedor termina de processar (o que pode levar de segundos a minutos, dependendo do banco emissor), ele dispara um webhook de volta pra healthtech confirmando o resultado. Um sábado de manhã, um deploy rotineiro no provedor de pagamento derruba o endpoint de webhook da healthtech por 40 minutos — nada dramático, um certificado TLS expirado num load balancer, corrigido rápido. O provedor de pagamento, como qualquer sistema de webhook bem desenhado, reenviou os eventos que falharam durante a janela de indisponibilidade, com backoff exponencial, e todos chegaram eventualmente. O problema não foi a perda de dados — foi o silêncio: ninguém no time da healthtech percebeu a janela de 40 minutos, porque não existia alerta configurado para “endpoint de webhook respondendo erro” — só para “API principal fora do ar”, que nunca chegou a cair. Os reembolsos ficaram em estado pending no banco da healthtech por até uma hora até os retries do provedor os resolverem, e o primeiro sinal de que algo tinha acontecido foi um ticket de suporte de uma clínica perguntando por que o status do reembolso dela “sumiu” por quase uma hora. Nada quebrou de forma permanente — mas o incidente expôs que o time tinha construído o happy path do webhook (receber, validar assinatura, processar) sem construir a disciplina operacional em volta dele: monitoramento do próprio endpoint receptor, alertas de fila de retry crescendo, e um dashboard pra saber, sem grep de log, quantos eventos estavam pendentes de reentrega. É exatamente esse gap — entre “o padrão funciona no design” e “o padrão sobrevive a um sábado de manhã com certificado vencido” — que esta nota tenta fechar.

O primeiro padrão: 202 Accepted e polling

A forma mais simples de lidar com uma operação que não cabe num request-response síncrono é não fingir que ela cabe. Em vez de o cliente esperar, com a conexão HTTP aberta, até a operação terminar — o que é frágil (qualquer timeout de proxy, load balancer ou biblioteca HTTP no meio do caminho derruba a conexão antes da resposta chegar) e caro (mantém um worker do servidor ocupado pelo tempo inteiro da operação) —, o servidor aceita o pedido, devolve uma resposta imediata reconhecendo que o trabalho começou, e deixa o cliente decidir como e quando checar o resultado.

O código de status que existe especificamente para isso, definido desde as primeiras versões do HTTP e reafirmado pela RFC 9110 (a especificação atual de semântica HTTP), é o 202 Accepted: a requisição foi aceita para processamento, mas o processamento ainda não terminou — e não há garantia, no próprio protocolo, de que vá terminar com sucesso (RFC 9110, §15.3.3).

POST /reports/exports HTTP/1.1
Content-Type: application/json
 
{ "format": "csv", "date_range": "2026-06" }
HTTP/1.1 202 Accepted
Location: /jobs/job_7f3a9c
 
{ "job_id": "job_7f3a9c", "status": "pending" }

A partir daí, o cliente consulta o recurso de acompanhamento — apontado pelo header Location, seguindo a mesma convenção usada em 201 Created para apontar pro recurso recém-criado — periodicamente, até o status mudar de pending/running para um estado terminal:

GET /jobs/job_7f3a9c HTTP/1.1
HTTP/1.1 200 OK
 
{ "job_id": "job_7f3a9c", "status": "running", "progress": 45 }
HTTP/1.1 200 OK
 
{ "job_id": "job_7f3a9c", "status": "completed", "result_url": "/exports/download/job_7f3a9c" }

sequenceDiagram
    participant C as Cliente
    participant S as Servidor

    C->>S: POST /reports/exports
    S-->>C: 202 Accepted<br/>Location: /jobs/job_7f3a9c

    loop Poll periódico
        C->>S: GET /jobs/job_7f3a9c
        S-->>C: 200 OK, status: running
    end

    C->>S: GET /jobs/job_7f3a9c
    S-->>C: 200 OK, status: completed<br/>result_url: /exports/download/...

    C->>S: GET /exports/download/job_7f3a9c
    S-->>C: 200 OK (arquivo)

Duas diretrizes de mercado — a Azure Architecture Center, no que chama de Asynchronous Request-Reply Pattern, e as diretrizes de API do Google Cloud/Fabric — convergem no mesmo desenho: o recurso de acompanhamento (Operation, job) tem um identificador, um status, e opcionalmente um resultado ou erro; enquanto a operação está em andamento, o endpoint de status responde 200 OK com o status intermediário (nunca 202 de novo — o 202 é só a resposta ao POST inicial); e quando termina, o servidor pode tanto embutir o resultado direto na resposta do status quanto redirecionar, via 303 See Other, para um recurso de resultado separado — 303 sinaliza corretamente ao cliente “vá buscar isso com GET”, diferente de um redirect genérico que poderia reenviar o método original (Microsoft Learn, Asynchronous Request-Reply Pattern; Google AIP-151, Long-running operations).

Um detalhe de design que aparece com frequência em entrevista: 10 segundos é a régua informal mais citada como limiar entre “resposta síncrona aceitável” e “isso precisa virar operação assíncrona” — não é uma regra do protocolo, é uma heurística de experiência do usuário, adotada explicitamente pelo Google AIP como ponto de partida (“a good rule of thumb is 10 seconds”) e reforçada por múltiplos guias de API design de mercado. Abaixo disso, um cliente esperando a resposta ainda é uma experiência razoável; acima, a percepção de “travou” começa, e seguir bloqueando a conexão é o design errado.

O padrão de polling tem um limite prático que vale nomear: ele funciona bem quando o cliente pode esperar e tem controle sobre o próprio loop de consulta, mas escala mal quando existem muitos clientes esperando por muitos jobs simultaneamente — cada poll é uma requisição HTTP inteira, com toda a sobrecarga de conexão, autenticação e roteamento que isso implica, multiplicada pelo intervalo de checagem. É exatamente esse custo que os outros dois padrões mencionados na literatura — webhook e Server-Sent Events/WebSocket — existem para evitar, cada um trocando “o cliente pergunta repetidamente” por “o servidor avisa quando muda”, com trade-offs próprios. SSE e WebSocket, para atualização em tempo real de progresso, já foram tratados em Comunicação em tempo real no primeiro sub-galho desta trilha; esta nota foca no terceiro caminho — o servidor empurrando o resultado via uma nova requisição HTTP, de servidor para servidor.

O segundo padrão: webhooks

Um webhook inverte o papel que toda a trilha até aqui assumiu como fixo: até agora, “seu sistema” era sempre o servidor, recebendo requisições de clientes. No modelo de webhook, seu sistema vira o cliente — ele é quem inicia a conexão HTTP, contra um servidor que pertence a quem se inscreveu para receber notificações. É o mesmo protocolo, a mesma semântica de request-response, só que com os papéis trocados: o “seu backend” de sempre não recebe mais nada, ele envia.

POST https://cliente.com/webhooks/pagamentos HTTP/1.1
Content-Type: application/json
X-Webhook-Event: payment.succeeded
X-Webhook-Id: evt_8f2a1c9d
X-Webhook-Signature: t=1720540800,v1=3d5e7f...
 
{
  "id": "evt_8f2a1c9d",
  "type": "payment.succeeded",
  "created_at": "2026-07-09T14:20:00Z",
  "data": {
    "payment_id": "pay_ab12",
    "amount": 15000,
    "currency": "BRL"
  }
}

Essa inversão de papel não é um detalhe estético — ela é a raiz de quase todo problema de confiabilidade que o resto desta seção resolve. Quando você é o servidor de uma API pública tradicional, você controla o próprio uptime, escolhe seu próprio timeout, e sabe exatamente quando algo deu errado do seu lado. Quando você é quem envia o webhook, você depende de um servidor que não controla — o endpoint do cliente pode estar fora do ar, atrás de um firewall mal configurado, respondendo devagar por estar sobrecarregado, ou simplesmente não existir mais porque alguém trocou de infraestrutura sem atualizar a URL cadastrada. E, ao contrário de uma chamada síncrona onde a falha aparece na hora, uma falha de entrega de webhook pode passar despercebida — como no incidente da healthtech na abertura desta nota — se ninguém estiver observando ativamente o próprio pipeline de envio.

Segurança: provar que o webhook é seu

Como o endpoint do cliente é, por natureza, um endpoint HTTP público (ou pelo menos acessível pela internet), qualquer um pode, em teoria, mandar um POST fingindo ser você — forjando um evento payment.succeeded falso, por exemplo, na esperança de que o sistema do cliente confie cegamente no payload e libere algo que não deveria (acesso, mercadoria, reembolso). A defesa padrão de mercado é assinatura criptográfica via HMAC, com um segredo compartilhado combinado previamente entre as duas partes:

signature = HMAC-SHA256(webhook_secret, timestamp + "." + corpo_do_request)

O formato consolidado pela Stripe — que virou referência de fato para praticamente todo provedor de webhook do mercado — embute o timestamp na própria assinatura, num header como t=1720540800,v1=3d5e7f...: t é o momento em que o evento foi assinado, v1 é o HMAC-SHA256 calculado sobre a concatenação do timestamp com o corpo bruto do request (Stripe Docs, Webhook signatures — via Hooklistener, Stripe Webhook Security Guide). Do lado de quem recebe, a verificação tem três passos:

  1. Recalcular o HMAC usando o mesmo segredo compartilhado, sobre o corpo bruto do request — nunca o corpo já parseado como JSON, porque reserializar um objeto pode alterar espaçamento, ordem de chaves ou formatação de números o suficiente para quebrar a assinatura.
  2. Comparar em tempo constante, não com uma comparação de string ingênua (==) — uma comparação normal retorna falso no primeiro byte divergente, o que teoricamente permite a um atacante medir o tempo de resposta e inferir a assinatura correta byte a byte; funções como crypto.timingSafeEqual (Node.js) ou hmac.compare_digest (Python) sempre levam o mesmo tempo, independente de onde a divergência ocorre (InventiveHQ, How HMAC Webhook Signatures Work).
  3. Validar o timestamp, rejeitando qualquer evento assinado com mais de alguns minutos de diferença do relógio local — a Stripe usa uma tolerância padrão de 5 minutos no próprio SDK. Isso é o que impede um replay attack: sem o timestamp na assinatura, um evento legítimo capturado por qualquer um com acesso à rede (um proxy comprometido, um log vazado) poderia ser reenviado indefinidamente, e a assinatura continuaria válida para sempre — porque nada na assinatura em si expira.

flowchart TD
    A["Webhook recebido<br/>com header de assinatura"] --> B["Recalcula HMAC sobre<br/>corpo BRUTO + timestamp"]
    B --> C{"Comparação em<br/>tempo constante bate?"}
    C -->|"Não"| D["Rejeita —<br/>401/400"]
    C -->|"Sim"| E{"Timestamp dentro<br/>da tolerância (~5min)?"}
    E -->|"Não"| F["Rejeita —<br/>possível replay"]
    E -->|"Sim"| G["Evento autêntico —<br/>segue para dedup"]

    style D fill:#D0021B,color:#fff
    style F fill:#D0021B,color:#fff
    style G fill:#4A90D9,color:#fff

Verificar a assinatura, mas manter o endpoint em HTTP

O que acontece: um time implementa toda a lógica de assinatura HMAC corretamente, incluindo comparação em tempo constante e validação de timestamp — mas o endpoint que recebe o webhook ainda aceita conexões em HTTP puro, sem TLS, “porque é só um ambiente interno” ou por um erro de configuração de infraestrutura que ninguém notou. Por quê: HMAC prova que o payload não foi alterado e veio de quem tem o segredo — mas não impede que o payload seja lido em trânsito por qualquer um posicionado na rede entre o remetente e o endpoint, se a conexão não é criptografada. Para eventos que carregam dados sensíveis (valores de pagamento, dados de paciente, tokens), isso expõe informação mesmo que a integridade da mensagem esteja formalmente garantida. Como evitar: HTTPS é pré-requisito não-negociável para qualquer endpoint de webhook que trafegue dado sensível — a assinatura HMAC protege contra forjamento e adulteração, não contra escuta de rede; as duas defesas são complementares, nunca substitutas uma da outra.

Confiabilidade: webhooks vão falhar, planeje para isso

A premissa de design correta para qualquer sistema que envia webhooks não é “o endpoint do cliente vai estar disponível” — é o oposto: o endpoint do cliente vai falhar em algum momento, por motivos completamente fora do seu controle, e o desenho precisa absorver isso sem perder eventos nem duplicar efeitos.

Retry com backoff exponencial. Quando o endpoint do cliente responde com erro (qualquer coisa fora da faixa 2xx) ou não responde dentro de um timeout curto (a Stripe recomenda que o endpoint responda em até 10 segundos), o remetente reagenda a entrega, aumentando o intervalo entre tentativas a cada falha — a Stripe, como referência de mercado, tenta por até 3 dias em modo produção, com um cadenciamento que a documentação oficial não publica em detalhe, mas que integrações de terceiros relatam como algo próximo de: imediato, ~5 minutos, ~30 minutos, ~2 horas, ~5 horas, ~10 horas, e depois a cada ~12 horas até fechar a janela de 3 dias (Stripe Docs, Webhooks; Hookdeck, Guide to Stripe Webhooks). O motivo do backoff crescer exponencialmente, em vez de tentar de novo a cada poucos segundos indefinidamente, é duplo: dá tempo real para o time do lado receptor perceber e corrigir o problema (um certificado vencido, um deploy quebrado) antes de esgotar as tentativas, e evita que um endpoint já sobrecarregado receba uma rajada de retries que só piora a situação.

Deduplicar do lado de quem recebe. Como o próprio mecanismo de retry implica que o mesmo evento pode chegar mais de uma vez — o remetente não tem como saber com certeza se um timeout significa “o cliente não recebeu” ou “o cliente recebeu, processou, e só a confirmação se perdeu” (o mesmo dilema fundamental tratado em Idempotência, só que agora do lado do consumidor de webhook em vez do cliente de API) —, todo webhook de qualidade carrega um identificador único e estável por evento: id na Stripe, X-Shopify-Webhook-Id na Shopify, webhook-id no padrão Standard Webhooks. Quem recebe deve armazenar esse ID (com constraint de unicidade no banco) e curto-circuitar qualquer processamento se o ID já foi visto — a mesma disciplina de idempotência da nota 01 deste sub-galho, aplicada no sentido inverso do fluxo HTTP.

Dead letter e dashboard de replay. Depois de esgotar as tentativas de retry sem sucesso, o evento não pode simplesmente desaparecer — ele precisa ser marcado como falho, ficar visível para quem opera o sistema, e idealmente reenviável manualmente. O GitHub, por exemplo, não reenvia automaticamente entregas falhas de webhook — mas mantém um histórico de “Recent deliveries” (últimos 3 dias na versão cloud, até 7 em algumas versões enterprise) com um botão de “Redeliver” por evento, além de uma API para redelivery programático (GitHub Docs, Redelivering webhooks). Esse par — visibilidade do que falhou, mecanismo de reenviar manualmente — é o que teria evitado o incidente da healthtech na abertura desta nota: não porque impede a falha (um certificado vencido continua sendo um certificado vencido), mas porque torna a falha visível em minutos, em vez de depender de um ticket de suporte para alguém perceber.

SituaçãoSem plano de confiabilidadeCom retry + dedup + dead letter
Endpoint do cliente cai por 40 minEventos daquela janela somem silenciosamenteRetries entregam tudo assim que o endpoint volta
Mesmo evento processado 2x por retryEfeito duplicado (cobrança, email, liberação de acesso)ID do evento deduplicado — só processa uma vez
Retries esgotam sem sucessoNinguém sabe que o evento nunca chegouFica marcado como falho, visível em dashboard, reenviável

Design do payload: evento fino ou evento gordo?

Uma decisão de design que aparece cedo em qualquer implementação de webhook, e que a indústria tem migrado de posição ao longo do tempo, é quanto de dado colocar dentro do próprio evento. Duas escolas:

  • Evento gordo (fat event): o payload carrega o estado completo do recurso no momento do evento — no exemplo desta nota, isso significaria incluir todos os campos do pagamento, não só o ID. Vantagem: quem recebe processa sem precisar fazer nenhuma chamada adicional de volta pra API.
  • Evento fino (thin event): o payload carrega só o tipo do evento e um identificador — o cliente, ao receber, faz uma chamada de API própria para buscar o estado atual do recurso, se precisar dos detalhes.

A vantagem do evento fino, que vem ganhando tração como prática recomendada mesmo em provedores historicamente “gordos” como a própria Stripe, é dupla: performance (menos dado trafegado, menos superfície de payload pra manter compatível ao longo do tempo) e, mais importante, correção sob concorrência — se dois eventos chegam fora de ordem (o próximo parágrafo detalha por que isso é a norma, não a exceção), um evento fino força o cliente a buscar o estado atual do recurso via API antes de agir, em vez de confiar cegamente no snapshot que veio embutido no evento mais antigo, que pode já estar desatualizado no momento em que é processado (Hookdeck, What Are Thin Events?; Hookdeck, Webhooks Fetch Before Process). Uma posição intermediária pragmática, adotada por boa parte do mercado, é incluir no payload só os campos que o consumidor típico precisa para decidir o que fazer a seguir (o ID do recurso, um status resumido, talvez um valor-chave), deixando o resto para uma consulta explícita à API se for necessário — nem tão magro que force uma chamada extra sempre, nem tão gordo que acople o formato do evento ao schema interno completo do recurso.

Eventos: naming, imutabilidade e o problema da ordem

O padrão de nomenclatura consolidado na indústria para o tipo do evento é recurso.ação, no singular e no passado (para ações já concluídas) — payment_intent.succeeded na Stripe, pull_request.opened no GitHub, orders/create na Shopify (com uma variação de separador, mas a mesma lógica semântica). A convenção existe porque espelha como desenvolvedores já pensam sobre APIs — um substantivo que identifica o quê, um verbo que identifica o quê aconteceu com ele — e porque nomes previsíveis facilitam filtrar e rotear eventos sem precisar inspecionar o payload inteiro (Svix, Webhook Event Naming Conventions).

Dois princípios adicionais completam o design de eventos:

  • Imutabilidade: um evento é um fato que já aconteceu — payment.succeeded não deveria, depois de emitido, ser “corrigido” ou reeditado. Se algo mudou depois (um estorno, por exemplo), isso vira um novo evento (payment.refunded), não uma alteração retroativa do evento anterior. Essa disciplina é o que permite qualquer consumidor tratar o histórico de eventos recebidos como um log confiável, sem se preocupar que um evento já processado possa “mudar de ideia” depois.
  • Versionamento do schema: quando o formato de um evento precisa evoluir de um jeito que quebraria consumidores existentes, a prática consolidada — a mesma discutida em Versionamento e evolução de contrato para o resto do contrato — é versionar o próprio tipo do evento em vez de mudar o payload silenciosamente. A Stripe resolve isso de um jeito específico e vale nomear: cada endpoint de webhook é fixado (pinned) numa versão da API no momento em que é criado, e todo evento futuro daquele endpoint é serializado nessa versão fixada — mesmo que a conta como um todo já tenha migrado pra uma versão mais nova da API. Para migrar um endpoint de webhook para uma versão mais nova sem quebrar nada, a recomendação é rodar dois endpoints em paralelo por um período — um na versão antiga, um na nova, ambos recebendo o mesmo evento em paralelo — até confirmar que o consumidor lida bem com o novo formato (Stripe Docs, Handle webhook versioning).

O ponto que mais surpreende quem está implementando um consumidor de webhook pela primeira vez é que ordem não é garantida — e isso não é uma falha do provedor, é uma consequência estrutural de como sistemas de entrega em escala funcionam: eventos podem ser processados por servidores diferentes, retentados em momentos diferentes após uma falha parcial, ou atrasados por variação de latência de rede, o que significa que subscription.deleted pode, de fato, chegar antes de subscription.created para a mesma assinatura, ou order.updated antes de order.created. A Stripe declara isso explicitamente na própria documentação, e a mesma ausência de garantia de ordem aparece na Shopify, na Paddle e na esmagadora maioria dos provedores de mercado (Hook Mesh, Why You Shouldn’t Rely on Webhook Ordering). Um consumidor bem desenhado, portanto, não assume sequência — ele trata cada evento como um sinal independente e, quando a ordem importa de fato (por exemplo, para reconstruir o estado atual de um recurso), busca o estado mais recente via API em vez de confiar que os eventos chegaram na sequência em que aconteceram. É a mesma lógica, aplicada de novo, por trás da recomendação de evento fino da seção anterior.

O terceiro padrão: bulk operations

O terceiro padrão de operação assíncrona resolve um problema diferente dos dois primeiros: não é “essa operação demora demais para uma resposta síncrona”, é “eu preciso fazer a mesma operação N vezes, e fazer N requisições HTTP separadas é caro e lento”. A resposta é aceitar uma lista de operações num único request e processar todas de uma vez:

POST /pacientes/bulk HTTP/1.1
Content-Type: application/json
 
{
  "operations": [
    { "action": "create", "data": { "name": "Alice" } },
    { "action": "create", "data": { "name": "Bob" } },
    { "action": "update", "id": 42, "data": { "email": "bob@example.com" } }
  ]
}

O ponto de decisão que separa uma implementação ingênua de uma implementação de produção é: o que acontece quando parte do lote falha e parte funciona? Existem, na prática, três respostas possíveis, e só duas delas são defensáveis:

  • Silêncio (evitar): devolver 200 OK genérico independente de quantos itens falharam de verdade, deixando o cliente adivinhar o que funcionou — essa opção é citada de forma consistente na literatura de API design como o antipadrão a evitar, porque esconde informação crítica atrás de um código de status que promete sucesso total.
  • Tudo ou nada (transacional): a operação inteira roda dentro de uma única transação de banco — se qualquer item falhar, tudo é revertido, e o cliente recebe um erro único descrevendo o que quebrou. Mais simples de implementar e de raciocinar sobre, mas força o cliente a corrigir um erro de cada vez e reenviar o lote inteiro de novo, mesmo que 999 dos 1000 itens estivessem perfeitos.
  • Parcial, com 207 Multi-Status: cada item do lote é processado independentemente, e a resposta carrega o resultado individual de cada um — sucesso ou erro — permitindo ao cliente saber exatamente o que passou e o que precisa ser corrigido, sem perder o trabalho que já deu certo.
HTTP/1.1 207 Multi-Status
Content-Type: application/json
 
{
  "results": [
    { "status": 201, "id": 100 },
    { "status": 201, "id": 101 },
    { "status": 422, "error": { "field": "email", "message": "formato inválido" } }
  ]
}

O código 207 Multi-Status nasceu na especificação do WebDAV (RFC 4918), não no HTTP core — mas se tornou o código de fato adotado pelo mercado sempre que uma resposta única precisa carregar múltiplos resultados independentes, mesmo fora do contexto original de WebDAV (Apidog, What Is Status Code: 207 Multi-Status?; discussão da Zalando sobre guidelines de bulk/207).

Duas considerações adicionais de design, menos discutidas mas igualmente importantes em produção: limite de tamanho (nunca aceitar um lote sem teto — 10 milhões de itens numa única requisição não é bulk, é um vetor de negação de serviço contra o próprio servidor) e ganho de performance real (bulk só se justifica se for de fato mais rápido, no agregado, do que N requisições individuais — se a implementação interna simplesmente faz um loop chamando a mesma lógica de criação individual item por item, sem nenhum ganho de I/O em lote no banco, a complexidade adicional do endpoint bulk não se paga). A Shopify, no design da própria API de bulk operations do GraphQL Admin, resolve esse problema de forma assíncrona e híbrida com o primeiro padrão desta nota: uma mutação bulkOperationRunQuery inicia o processamento em background, e o cliente acompanha via polling do campo status da operação ou, alternativamente, via um webhook de conclusão — a própria documentação recomenda o webhook sobre polling justamente para reduzir chamadas redundantes de API (Shopify Dev Docs, Perform bulk operations with the GraphQL Admin API) — uma confirmação direta de que os três padrões desta nota não são alternativas isoladas, mas frequentemente se combinam no mesmo desenho.

O fio que amarra os três padrões: webhooks são mensageria invertida

Voltando ao ponto que abriu a seção de webhooks: quando seu sistema vira o cliente que envia um POST para outro servidor, ele herda exatamente a mesma classe de problema que um consumidor de fila de mensageria enfrenta — só que a “fila”, nesse caso, não é uma abstração de infraestrutura com garantias formais, é a infraestrutura HTTP crua do lado do destinatário.

O paralelo é direto, item por item:

ProblemaMensageria (fila/stream)Webhook
Garantia de entregaAt-least-once é a norma prática — o broker reentrega até o consumidor confirmar (ack)Retry até esgotar a janela (ex.: 3 dias na Stripe) — o “consumidor” confirma implicitamente respondendo 2xx
Duplicação possívelSim — reentrega após timeout de ack, rebalanceamento de partiçãoSim — mesmo evento reenviado após timeout ou erro transitório
Solução para duplicaçãoIdempotência no consumidor, deduplicação por ID de mensagemIdempotência no consumidor, deduplicação por ID de evento
Ordem garantidaSó dentro de uma partição/fila FIFO — entre partições, nãoNão, na esmagadora maioria dos provedores — explicitamente documentado assim
O que fazer quando falha demaisDead letter queue — mensagem sai do fluxo normal, fica visível para inspeção manualMarcar como falho, visível em dashboard, reenviável manualmente
Quem inicia a comunicaçãoO broker empurra para o consumidor (ou o consumidor puxa, a depender do modelo)Quem envia o evento empurra para o endpoint do destinatário

A diferença real entre os dois mundos não está no problema — é estruturalmente o mesmo problema de garantia de entrega sob falha parcial — mas no mecanismo por trás da garantia. Uma fila de mensageria de verdade (Kafka, RabbitMQ, SQS) é construída, desde a base, com durabilidade formal: a mensagem existe fisicamente em disco, replicada, esperando confirmação de consumo, independente de quem está do outro lado estar disponível ou não. Um webhook não tem esse chão embaixo por padrão — se quem envia não implementar retry, fila interna e dead letter por conta própria, uma falha do lado do destinatário simplesmente perde o evento, sem nenhuma rede de segurança. É justamente por isso que sistemas de webhook maduros, na prática, constroem uma fila de mensageria interna entre o evento de origem e o envio HTTP externo — o webhook nunca deixa de ser, no fundo, uma fila com uma cara de API pública na ponta de saída.

Essa é a ponte que fecha este sub-galho e abre o próximo: tudo que apareceu aqui — at-least-once, deduplicação por ID, ausência de garantia de ordem, dead letter — reaparece, com o mesmo nome e a mesma lógica de fundo, no próximo sub-galho desta trilha, que mergulha de cabeça em mensageria e comunicação assíncrona de verdade: filas de tarefa, streams de eventos, e os brokers (Kafka, RabbitMQ, SQS) que implementam essas garantias como infraestrutura de primeira classe, em vez de reconstruídas manualmente em cima de HTTP.

Casos práticos

Exportação de relatório grande, com fallback de polling quando o webhook falha. Uma plataforma de marketplace de saúde oferece exportação de relatórios financeiros mensais para clínicas parceiras — uma operação que, para clínicas grandes, pode levar minutos processando milhares de registros. O fluxo aceita ambos os padrões: o cliente pode fornecer um callback_url no POST /reports/exports (e recebe um webhook quando pronto) ou, se não fornecer, cai automaticamente no padrão de polling via GET /jobs/{id}. Uma clínica configura o callback_url, mas o firewall corporativo dela bloqueia requisições de entrada não anunciadas — o webhook nunca chega, mesmo com retry. Como o sistema também expõe o job de acompanhamento por polling, o time de suporte da clínica consegue, mesmo sem o webhook funcionando, checar manualmente o status via GET /jobs/{id} e recuperar o relatório — o design que oferece os dois caminhos em paralelo evita que uma falha de infraestrutura do lado do cliente vire um bloqueio total.

Webhook de pagamento processado fora de ordem, resolvido com fetch-before-process. Voltando ao exemplo de abertura: durante os 40 minutos de indisponibilidade do endpoint da healthtech, o provedor de pagamento acumula uma fila de retries pendentes para múltiplos eventos do mesmo pagamento — payment.processing, seguido minutos depois por payment.succeeded. Quando o endpoint volta, os retries de ambos os eventos chegam, mas fora de ordem: payment.succeeded chega antes de payment.processing, porque cada evento tem seu próprio cronograma de retry independente. Como o consumidor foi desenhado seguindo o princípio de evento fino — buscando o estado atual do pagamento via API a cada evento recebido, em vez de confiar no snapshot embutido —, o resultado final está correto de qualquer forma: não importa em que ordem os dois eventos chegam, a última consulta ao estado real do pagamento sempre reflete o que de fato aconteceu.

Bulk import de pacientes com validação parcial. Uma clínica sobe uma planilha de 2.000 pacientes migrando de outro sistema, convertida para um POST /pacientes/bulk. O endpoint processa cada registro de forma independente — sem transação única cobrindo o lote inteiro, porque os registros não têm dependência lógica entre si — e devolve 207 Multi-Status com 1.987 sucessos e 13 erros de validação (emails duplicados, CPFs mal formatados). A equipe de operação da clínica corrige só os 13 registros problemáticos e reenvia um segundo lote menor, sem precisar re-subir os 1.987 que já foram importados corretamente — o desenho parcial economiza retrabalho real, comparado a um tudo-ou-nada que teria descartado a importação inteira por 13 linhas com problema.

Em entrevista

Uma pergunta clássica de entrevista sênior de backend é “como você desenharia a confirmação de um pagamento assíncrono processado por um provedor terceiro?” — e a resposta que sinaliza profundidade real não para em “eu usaria um webhook”. Ela nomeia a inversão de papel explicitamente: “meu sistema vira cliente HTTP do provedor, o que significa que herdo os mesmos problemas de confiabilidade que qualquer cliente tem — preciso assumir que o endpoint que estou chamando pode estar fora do ar, e desenhar retry com backoff exponencial em vez de tratar a primeira falha como definitiva.” Um segundo sinal forte é levantar a questão de segurança sem que o entrevistador precise puxar: “assino o payload com HMAC, incluindo o timestamp na assinatura — sem timestamp, um evento legítimo capturado uma vez poderia ser reenviado indefinidamente como replay, porque a assinatura por si só nunca expira.”

Vale nomear com precisão a diferença entre os dois padrões desta nota: “202 Accepted com polling é o cliente perguntando repetidamente se terminou; webhook é o servidor avisando quando terminar — a troca é entre controle total do lado do cliente sobre quando consultar, versus latência quase zero de notificação, com o custo de que agora quem envia o webhook precisa lidar com a possibilidade real de o destino estar indisponível.” E, se a entrevista aprofundar em confiabilidade de webhook especificamente, mencionar que ordem de entrega não é garantida — e que a solução correta não é tentar impor ordem artificialmente, é desenhar o consumidor para buscar o estado atual via API em vez de confiar no snapshot do evento — costuma separar quem já debugou um bug real de “evento chegou fora de ordem” de quem só leu sobre o padrão em um tutorial.

How to explain in English

“There are three patterns for handling work that doesn’t fit a synchronous request-response: the 202 Accepted plus polling pattern, webhooks, and bulk operations. For long-running work — anything that takes more than a few seconds — the server responds 202 Accepted immediately with a Location header pointing to a status resource, and the client polls that resource until it reaches a terminal state. Google’s API guidelines use ten seconds as the rough threshold for when an operation should stop being synchronous.

A webhook flips the client-server relationship: instead of the client polling you, you become the client and push an HTTP POST to a URL the subscriber registered. That inversion is the source of almost every reliability problem webhooks have — you no longer control the uptime of the endpoint you’re calling. So a production webhook sender needs three things: HMAC signature verification with a timestamp baked into the signature, specifically to prevent replay attacks, since a signature with no expiry could be captured once and replayed forever; exponential-backoff retries — Stripe retries for up to three days in live mode — because the receiving endpoint will go down sometimes, and that’s not a bug, it’s an assumption you design around; and deduplication on the receiving end, keyed by a stable event ID, because at-least-once delivery means the same event can legitimately arrive more than once. A detail that surprises people the first time: webhook ordering is explicitly not guaranteed by virtually every major provider — Stripe says so outright — so a well-built consumer treats each event as an independent signal and fetches the current resource state via API rather than trusting the payload’s snapshot, instead of assuming events arrive in the order they happened.

Bulk operations solve a different problem — processing many items in one request instead of N separate ones — and the key design decision is what happens when part of the batch fails. 207 Multi-Status, borrowed from WebDAV, lets each item report its own result independently, which is almost always better than an all-or-nothing transaction unless the items are logically coupled and a partial success would leave the system in an invalid state.

The thread that ties all three together: a webhook is, structurally, message queuing with no formal infrastructure behind it — the same at-least-once delivery, the same need for idempotent consumers, the same lack of ordering guarantees you’d get from Kafka or RabbitMQ, except the ‘queue’ is just the raw HTTP surface on the receiving end. Mature webhook senders end up building an actual internal queue in front of the HTTP delivery step, because that’s the only way to get real durability guarantees instead of reconstructing them by hand.”

PTEN
Operação de longa duraçãoLong-running operation
Consulta periódica / sondagemPolling
WebhookWebhook
Callback HTTPHTTP callback
Assinatura HMACHMAC signature
Ataque de replayReplay attack
Comparação em tempo constanteTiming-safe / constant-time comparison
Retry com backoff exponencialExponential backoff retry
Entrega pelo menos uma vezAt-least-once delivery
DeduplicaçãoDeduplication
Fila de mensagens mortasDead letter queue
Reenvio manualManual redelivery / replay
Evento fino / evento gordoThin event / fat event
Ordem de entrega não garantidaDelivery ordering not guaranteed
Operação em loteBulk operation
Sucesso parcialPartial success
Tudo ou nada (transacional)All-or-nothing (transactional)

O que vem a seguir

Este sub-galho tratou de como o contrato síncrono se sustenta sob falha, retry e o tempo — idempotência, evolução de versão, caching, rate limiting, e agora os três padrões para quando a resposta simplesmente não cabe num request-response imediato. O fio que webhooks deixaram amarrado — a mesma disciplina de garantia de entrega, deduplicação e ordenação que aparece aqui reaparece, com infraestrutura de verdade por trás, em filas e streams de eventos — é exatamente o ponto de entrada do próximo sub-galho da trilha: Comunicação assíncrona, um mergulho em message queue vs event streaming, garantias de entrega formais, o padrão Outbox para transações distribuídas, e o legado de ESB/JMS que a indústria foi deixando para trás. Esse sub-galho ainda não existe como notas escritas nesta trilha — é o próximo passo do roteiro.

Veja também

Fontes