At-least-once vs. exactly-once: garantias de entrega de webhooks
Se você já processou um webhook duas vezes e cobrou o cliente em dobro, ou passou horas depurando por que a contagem de estoque saiu do lugar, você já encontrou o conceito mais mal compreendido dos sistemas distribuídos: as garantias de entrega.
Webhooks operam com entrega at-least-once. A entrega exactly-once é uma impossibilidade comprovada na computação distribuída, não um recurso que algum fornecedor possa entregar. O que o mercado chama de "exactly-once" é, na verdade, entrega at-least-once combinada com processamento idempotente do lado do receptor.
Entender essa distinção não é academicismo. Ela determina como você arquiteta os seus consumidores de webhooks, para quais modos de falha você se prepara e se um evento duplicado corrompe os seus dados silenciosamente ou é tratado com elegância.
Por que a entrega exactly-once é impossível
A impossibilidade da entrega exactly-once não é algo que se possa superar. É uma restrição matemática, enraizada no Problema dos Dois Generais e no resultado de impossibilidade FLP (Fischer, Lynch, Patterson, 1985).
O problema central é enganosamente simples. Quando o emissor de um webhook não recebe uma confirmação, ele não consegue distinguir entre quatro cenários:
- A mensagem se perdeu no caminho
- A mensagem foi entregue, mas a confirmação se perdeu
- O receptor caiu antes de processar
- A rede está apenas lenta
Nos cenários 2 e 4, o evento já foi entregue. Tentar de novo cria uma duplicata. Mas nos cenários 1 e 3, não tentar de novo significa perda de dados. Qualquer sistema confiável precisa tentar de novo — o que significa que duplicatas são inevitáveis.
A entrega exactly-once é um ideal inalcançável por causa de uma restrição fundamental da computação distribuída, que não pode ser contornada, apenas acomodada com estratégias no nível da aplicação.
Comparando as três garantias de entrega
Todo sistema de webhooks faz um trade-off entre confiabilidade e simplicidade. Veja como os três tipos de garantia se comparam:
| At-most-once | At-least-once | Processamento exactly-once | |
|---|---|---|---|
| Como funciona | Fire-and-forget. Sem retries. | Persiste + tenta de novo até receber confirmação. | Entrega at-least-once + consumidor idempotente. |
| Trade-off operacional | Mais simples de implementar; aceita perda de dados. | Entrega confiável; precisa lidar com duplicatas. | Mais confiável; exige lógica do lado do receptor. |
| Padrão de implementação | Uma única chamada HTTP, ignorando falhas. | Fila de retry com backoff exponencial. | Tabela de dedup + processamento transacional. |
| Adequação: webhooks de saída | Apenas notificações de baixo valor. | Padrão da maioria dos provedores (Stripe, Shopify). | Exige cooperação do consumidor — o provedor não garante sozinho. |
| Adequação: webhooks de entrada | Aceitável para operações idempotentes. | Escolha padrão para ingestão confiável. | Necessário para operações financeiras ou com estado. |
| Modo de falha comum | Perda silenciosa de dados. | Processamento duplicado, cobranças em dobro, contagens infladas. | Expiração da janela de dedup, evicção de cache. |
Exactly-once é uma garantia de processamento, não de entrega. Ela exige participação ativa tanto do emissor quanto do receptor. Nenhuma camada de infraestrutura sozinha consegue oferecê-la.
Quando at-least-once é suficiente
Nem todo webhook precisa de tratamento de idempotência. Entrega at-least-once sem deduplicação adicional é perfeitamente adequada quando se trata de:
- Invalidação de cache: receber "produto atualizado" duas vezes apenas atualiza o cache duas vezes — nenhum dano.
- Logs e analytics: uma entrada de log ou um evento de analytics duplicado costuma ser irrelevante.
- Sincronizações de estado não financeiras: se o seu handler busca o estado mais recente em uma API a cada webhook (em vez de aplicar um delta), processar duas vezes produz naturalmente o mesmo resultado.
Isso se alinha ao padrão de mercado de projetar sistemas que reportam o estado atual, em vez de mutar o estado com mudanças incrementais. Quando o seu handler lê o estado atual em vez de aplicar deltas, receber o mesmo webhook duas vezes não tem efeito adverso.
Quando você precisa de idempotência
Processamento idempotente se torna crítico quando duplicatas causam dano real:
- Transações financeiras: cobrar um cliente duas vezes, emitir estornos duplicados ou creditar uma conta em dobro.
- Gestão de estoque: decrementar a contagem de estoque duas vezes por um único pedido.
- Comunicações com usuários: enviar e-mails, SMS ou push notifications duplicados.
- Qualquer operação com efeitos colaterais externos: chamar uma API de terceiros, disparar um workflow ou escrever em um sistema que não lida bem com duplicatas.
Se o seu handler de webhook muta estado ou dispara ações irreversíveis, você precisa de idempotência.
Como as duplicatas realmente acontecem: exemplos de Stripe e Shopify
Entender de onde vêm as duplicatas ajuda a projetar defesas contra elas.
A Stripe pode enviar o mesmo evento mais de uma vez por causa de problemas de rede, de retries disparados por respostas lentas do endpoint ou porque o seu endpoint retornou um status fora da faixa 2xx depois de já ter processado o evento com sucesso. Todo evento da Stripe carrega um event.id (por exemplo, evt_1NB4kC2eZvKYlo2CKLmBxSJn) especificamente para deduplicação.
As duplicatas da Shopify surgem de timeouts de rede, respostas lentas e problemas de infraestrutura. A empresa também observa que múltiplas assinaturas de webhook para o mesmo topic vão gerar webhooks legítimos separados — esses não são duplicatas e terão headers X-Shopify-Event-Id diferentes. Cada webhook inclui esse header justamente para que as aplicações possam deduplicar.
Implementando processamento idempotente de webhooks
O padrão é consistente, independentemente do provedor. Extraia um identificador único do evento, verifique se você já o processou e use constraints do banco de dados para lidar com condições de corrida:
async function handleWebhook(req, res) {
// Extract the unique event ID from the provider
const eventId =
req.headers["x-shopify-event-id"] || // Shopify
req.body.id || // Stripe (evt_XXXX)
req.headers["x-event-id"]; // Generic
// Check if already processed
const alreadyProcessed = await db.query(
"SELECT 1 FROM processed_events WHERE event_id = $1",
[eventId]
);
if (alreadyProcessed.rows.length > 0) {
return res.status(200).send("OK"); // Acknowledge safely
}
try {
await db.transaction(async (tx) => {
// Insert with unique constraint — catches race conditions
await tx.query(
`INSERT INTO processed_events (event_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (event_id) DO NOTHING`,
[eventId]
);
// Process business logic inside the same transaction
await processBusinessLogic(tx, req.body);
});
return res.status(200).send("OK");
} catch (err) {
if (err.code === "23505") {
// Unique violation — another instance already processed this
return res.status(200).send("OK");
}
throw err;
}
}
Dois detalhes importam aqui. Primeiro, a checagem de dedup e a lógica de negócio precisam acontecer dentro da mesma transação de banco de dados. Sem isso, uma queda entre a checagem e o insert abre uma janela para duplicatas. Segundo, a cláusula ON CONFLICT DO NOTHING (ou capturar a violação da constraint de unicidade) resolve a condição de corrida em que duas instâncias do seu handler processam o mesmo evento simultaneamente.
Lembre-se de limpar periodicamente a sua tabela processed_events. As idempotency keys da Stripe, por exemplo, são válidas por 24 horas — uma linha de base razoável para a sua própria janela de dedup.
Escolhendo a idempotency key certa
O padrão acima é tão confiável quanto a chave sobre a qual você deduplica, e existem dois tipos de chave com garantias diferentes.
Um ID de evento do provedor (evt_... da Stripe, X-Shopify-Event-Id da Shopify) deduplica reenvios exatos do mesmo evento. Ele resolve tempestades de retry e reenvios, mas não cobre o caso em que um provedor emite dois eventos diferentes (com dois IDs diferentes) para a mesma ação subjacente.
Uma chave de negócio derivada do payload (um ID de payment intent, um ID de pedido, uma idempotency key que você definiu na requisição original) deduplica sobre a operação em si. Dois eventos com IDs diferentes que representam "a cobrança pi_123 foi aprovada" colapsam em uma única chave de negócio, e você processa a cobrança uma vez só.
Use o ID de evento do provedor como padrão. Recorra a uma chave de negócio quando a mesma ação puder chegar até você por mais de um evento, ou quando você também precisar de proteção contra duplicatas geradas antes do provedor.
Deduplicando com Redis em endpoints de alto volume
Uma ida ao banco a cada evento é aceitável em volume moderado. Em alto volume, um SET no Redis com NX (definir apenas se não existir) e EX (expiração) dá um check-and-claim atômico em um único comando:
async function claimEvent(eventId) {
// NX = only set if the key doesn't exist; EX = expire after the dedup window
const claimed = await redis.set(`webhook:${eventId}`, "1", "NX", "EX", 86400);
return claimed === "OK"; // true = first time we've seen this event
}
async function handleWebhook(req, res) {
const eventId = req.headers["x-shopify-event-id"] || req.body.id;
if (!(await claimEvent(eventId))) {
return res.status(200).send("OK"); // already claimed — safe to drop
}
await processBusinessLogic(req.body);
return res.status(200).send("OK");
}
Trate o Redis como um filtro rápido, não como fonte da verdade. Como as chaves expiram e o Redis pode fazer evicção sob pressão de memória, uma duplicata que chegar depois da evicção vai passar. Para qualquer coisa com efeitos colaterais financeiros ou de estado, mantenha a constraint do banco do padrão anterior como a rede de proteção definitiva e use o Redis para absorver o grosso do tráfego antes que ele chegue ao banco. Para uma comparação mais completa dos trade-offs entre banco de dados, Redis e armazenamento em memória, veja como implementar idempotência em webhooks.
Idempotência natural: deixe o seu schema fazer o trabalho
A abordagem mais robusta evita completamente uma tabela de dedup separada, tornando a própria escrita de negócio idempotente. Se o registro que você cria já tem uma chave de negócio única, um upsert faz do reprocessamento uma operação sem efeito:
INSERT INTO orders (order_id, customer_id, amount, status)
VALUES ($1, $2, $3, 'paid')
ON CONFLICT (order_id) DO NOTHING;
Aqui, a constraint de unicidade em orders.order_id é o seu mecanismo de deduplicação. Não há janela de dedup para ajustar nem job de limpeza para rodar, porque a constraint dura tanto quanto os dados. Quando o efeito principal do seu handler é um único insert ou update chaveado por um identificador de negócio, prefira isso a uma tabela processed_events dedicada.
O que os fornecedores realmente querem dizer com "exactly-once"
Quando fornecedores de infraestrutura de webhooks afirmam oferecer "entrega exactly-once", normalmente estão se referindo a um sistema de defesa em camadas, e não a uma violação da teoria dos sistemas distribuídos. Veja o que essas camadas de fato pegam — e o que deixam passar:
| Camada | O que ela pega | O que ela deixa passar |
|---|---|---|
| Deduplicação na infraestrutura | Tempestades de retry, duplicatas quase simultâneas | Duplicatas tardias, fora da janela de dedup |
| Suporte a idempotency key | Reenvios exatos do mesmo evento | Duplicatas do lado do provedor com IDs de evento diferentes |
| Idempotência no nível da aplicação | Tudo — a última linha de defesa | Nada, se estiver bem implementada |
| Controles de ordenação | Processamento fora de ordem | Eventos que chegam fora de ordem desde a origem |
| Lógica de retry + circuit breakers | Falhas transitórias e prolongadas no destino | Falhas intermitentes entre as tentativas de retry |
A conclusão crítica é que afirmações de "exactly-once" dependem inteiramente das fronteiras do sistema. Um fornecedor pode garantir exactly-once dentro da própria infraestrutura, deduplicando retries antes que cheguem ao seu endpoint. Mas não tem controle sobre o que acontece depois que o seu endpoint recebe o evento. Se o seu handler cair depois de retornar 200 mas antes de commitar no seu banco, o evento se perde do ponto de vista do seu sistema, e nenhuma deduplicação em nível de infraestrutura resolve isso.
É por isso que processamento exactly-once exige participação do endpoint. Fornecedores que afirmam garantias "mágicas" de exactly-once sem falar das responsabilidades do lado do receptor estão, na melhor das hipóteses, descrevendo apenas uma camada do modelo de defesa.
Um roteiro para escolher o seu nível de garantia
Ao avaliar infraestrutura de webhooks ou projetar os seus próprios consumidores, use este roteiro de decisão:
Comece com entrega at-least-once. É o padrão do mercado e o default correto. Depois acrescente garantias adicionais conforme o seu caso de uso:
- Os seus handlers são naturalmente idempotentes? Se processar o mesmo evento duas vezes produz o mesmo resultado (por exemplo, definir um status como "enviado" em vez de incrementar um contador), at-least-once é suficiente, sem trabalho adicional.
- Você processa transações financeiras ou gerencia estoque? Adicione idempotência no nível da aplicação usando o padrão de dedup acima. Isso não é negociável.
- A ordem dos eventos importa? Se processar "order.updated" antes de "order.created" corromperia o seu estado, você precisa de controles de ordenação — seja no nível da infraestrutura, seja verificando números de sequência ou timestamps no seu handler.
- Você recebe webhooks em alto volume de múltiplos provedores? Considere deduplicação no nível da infraestrutura (janelas de dedup configuráveis podem pegar o grosso das duplicatas) para reduzir a carga sobre a sua camada de dedup na aplicação.
- Você precisa se recuperar de indisponibilidades? Garanta que o seu provedor suporte replay e recuperação de eventos para que você consiga preencher os eventos perdidos sem redisparar os que já foram processados.
Conclusão
A comunidade de sistemas distribuídos encerrou esse debate décadas atrás: entrega exactly-once é impossível, e processamento exactly-once é uma preocupação no nível da aplicação, que exige a sua participação ativa. Todo provedor relevante de webhooks — Stripe, Shopify e as camadas de infraestrutura entre eles — opera com entrega at-least-once.
Isso não é uma limitação a ser contornada. É uma restrição de projeto com a qual construir. Aceite que duplicatas vão chegar, implemente processamento idempotente onde isso importa e use deduplicação no nível da infraestrutura para reduzir o ruído. A combinação de entrega at-least-once com consumidores idempotentes é a arquitetura correta para processamento confiável de webhooks em sistemas distribuídos, não um meio-termo.
FAQs
O que é entrega at-least-once de webhooks?
Entrega at-least-once significa que o sistema garante que todo evento de webhook será entregue pelo menos uma vez, repetindo a tentativa em caso de falha até que o receptor confirme o recebimento. O trade-off é que entregas duplicadas são possíveis e esperadas — a sua aplicação precisa lidar com elas de forma segura, com processamento idempotente.
Entrega exactly-once de webhooks é possível?
Entrega exactly-once é impossível em sistemas distribuídos por causa de restrições fundamentais como o Problema dos Dois Generais. No entanto, processamento exactly-once é alcançável combinando entrega at-least-once com handlers de evento idempotentes. Essa é a abordagem usada por Stripe, Kafka, AWS e Hookdeck.
Como lidar com entregas duplicadas de webhooks?
Implemente handlers de webhook idempotentes usando um identificador único do evento (como um ID de pagamento, um ID de pedido ou o header x-hookdeck-eventid). Antes de processar, verifique se o evento já foi tratado. Use constraints de unicidade no banco de dados, deduplicação baseada em Redis ou um cache de idempotency key para evitar efeitos colaterais duplicados.
Qual garantia de entrega a Hookdeck oferece?
A Hookdeck oferece entrega at-least-once com recursos de infraestrutura que ajudam você a alcançar processamento exactly-once. Isso inclui retries automáticos com backoff configurável, regras de deduplicação de eventos, headers de idempotência em cada evento, gestão de dead letter queue por meio de Issues e persistência durável dos eventos.
Qual é a relação entre idempotência e garantias de entrega?
A idempotência preenche a lacuna entre entrega at-least-once e processamento exactly-once. Como a entrega at-least-once implica que duplicatas são esperadas, handlers idempotentes garantem que processar o mesmo evento várias vezes produza o mesmo resultado que processá-lo uma vez — dando a você a confiabilidade da entrega garantida sem o risco de efeitos colaterais duplicados.
O que acontece quando a entrega de um webhook falha?
Quando a entrega falha, o sistema tenta novamente conforme a política de retry configurada (backoff exponencial ou linear). Se todas as tentativas se esgotarem, o evento vai para uma dead letter queue para investigação e recuperação manual. Com a Hookdeck, eventos com falha são acompanhados como Issues, com visibilidade total do payload, e podem ser reprocessados individualmente ou em lote depois que a causa raiz for corrigida.
Infraestrutura de webhooks, gerenciada para você
A Hookdeck cuida da ingestão, entrega, observabilidade e recuperação de erros — para que você não precise.