Dois webhooks podem ser autênticos, ter IDs diferentes e ainda assim produzir uma atualização errada. Isso acontece quando a aplicação trata a sequência de chegada como a sequência das mudanças no negócio. Um evento atrasado passa a ter a última palavra apenas porque foi processado por último.
Em um serviço por assinatura que encerra o acesso no cancelamento imediato, aplicar depois um retrato antigo de assinatura ativa pode liberar novamente o que já havia sido bloqueado. O mesmo risco aparece em pedidos, entregas e cadastros. Deduplicar evita repetir um evento; lidar com eventos distintos exige entender o que cada um representa e qual informação permite decidir o estado atual.
A sequência no seu endpoint é apenas a sequência de chegada
As garantias de entrega dependem do provedor: confira se existe ordenação, como funcionam as reentregas e qual identidade acompanha cada evento. A documentação da Stripe, por exemplo, informa que seus eventos podem chegar fora de ordem e orienta a tratar duplicatas. Uma tentativa pode falhar, entrar em retry e chegar depois de outra que encontrou o servidor saudável. Ainda existe a concorrência local: receber A antes de B não impede que o worker de B termine primeiro.
Quando uma assinatura ativa é cancelada às 10:05, a ordem de recebimento pode inverter a sequência das mudanças:
| Momento no provedor | Evento | Ordem de recebimento | Aplicação ingênua |
|---|---|---|---|
| 10:00 | evt_antigo: assinatura ativa | Segundo | Reativa o acesso |
| 10:05 | evt_novo: assinatura cancelada | Primeiro | Remove o acesso |
Um índice único por ID aceita os dois, corretamente. Um ORDER BY received_at reproduz a inversão, também corretamente do ponto de vista do recebimento. O defeito aparece quando a aplicação usa essa ordem para decidir qual retrato é mais recente.
A chave de idempotência em APIs continua útil para comandos enviados ao provedor, mas não estabelece uma sequência para as notificações recebidas. São identidades diferentes: a intenção de criar uma operação, o evento produzido e a tentativa de entregar esse evento.
Separe uma mudança de estado de um fato que precisa ser registrado
Antes de construir a fila, classifique o efeito desejado. No serviço de assinaturas, “qual é o estado atual desta assinatura?” é uma pergunta de projeção: a aplicação mantém uma cópia local do recurso. “Esta fatura foi paga?” é um fato identificado por uma fatura. “Devemos enviar o recibo?” já envolve um efeito externo.
Essa distinção evita uma solução universal que resolve o painel e quebra o financeiro. Para atualizar uma projeção, consultar o recurso atual pode tornar irrelevante um retrato atrasado. Para registrar pagamentos, dois eventos de faturas diferentes precisam continuar sendo dois fatos, mesmo que a assinatura já tenha mudado. Para enviar recibos, cada ação precisa de uma identidade que sobreviva ao reprocessamento.
O conteúdo da notificação também depende do contrato. Ela pode trazer um retrato do recurso no momento da mudança ou apenas dados suficientes para identificar o que deve ser consultado. Um retrato pode estar desatualizado quando for consumido. Recuperar os detalhes daquele evento preserva seu contexto histórico; consultar o recurso atual responde a outra pergunta. Escolha a leitura conforme o efeito que precisa produzir.
Sincronizar dados de cobrança também não define, por si só, quem pode acessar o produto. Um cancelamento imediato e um cancelamento ao fim do período podem ter consequências diferentes. A projeção fornece os dados; a aplicação decide acesso conforme suas regras. Uma notificação atrasada de estado ativo não deve desfazer um cancelamento que, pelas regras do produto, já encerrou o acesso.
Primeiro, receba com segurança e guarde uma obrigação recuperável
O endpoint público deve verificar a autenticidade pelo método previsto na integração, identificar conta e ambiente de origem, conferir o tipo de evento e persistir o trabalho aceito. Quando a assinatura criptográfica depende dos bytes recebidos, preserve o corpo original: desserializar e remontar o JSON pode alterar o material verificado. Use o verificador oficial quando disponível e respeite as regras de validade e proteção contra repetição do contrato.
Uma inbox é uma tabela de entrada que conserva o evento e seu estado de processamento. Ela permite responder ao HTTP depois de um commit durável e continuar em segundo plano. O esquema reduzido abaixo usa PostgreSQL e nomes do modelo local para registrar a identidade composta, o recurso afetado e o conteúdo necessário. A definição da tabela é feita uma vez; o INSERT pertence ao recebimento:
CREATE TABLE webhook_inbox (
provider text NOT NULL,
source_account text NOT NULL,
environment text NOT NULL,
event_id text NOT NULL,
resource_id text NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
PRIMARY KEY (provider, source_account, environment, event_id)
);
INSERT INTO webhook_inbox (
provider,
source_account,
environment,
event_id,
resource_id,
payload
)
VALUES (
:provider,
:source_account,
:environment,
:event_id,
:resource_id,
CAST(:payload AS jsonb)
)
ON CONFLICT (provider, source_account, environment, event_id)
DO NOTHING
RETURNING event_id;
Os parâmetros representam valores vinculados pelo driver, extraídos depois da verificação. A conta de origem precisa ser autorizada para a integração; um tenant_id arbitrário no corpo não pode escolher a conta interna. O escopo composto evita confundir eventos de contas ou ambientes diferentes. resource_id, neste consumidor, é a assinatura cuja projeção será atualizada.
A restrição única fecha a corrida entre dois recebimentos simultâneos. Fazer SELECT para verificar existência e só depois INSERT deixa uma janela em que ambos podem achar que o evento é novo. Com ON CONFLICT DO NOTHING, uma entrega autenticada já persistida pode receber sucesso sem recriar o trabalho. Isso não significa que ela já terminou: o worker ainda precisa acompanhar os pendentes.
O commit deve acontecer antes da resposta de sucesso. Se o banco falhar, não confirme um recebimento que só existe na memória. O GitHub, por exemplo, exige resposta 2xx em até dez segundos e recomenda processamento assíncrono. Esse prazo pertence ao GitHub; cada integração precisa conferir seu próprio contrato.
Usar a inbox como fila de origem também evita uma segunda escrita obrigatória no broker. Um worker pode consultar pendências, e uma notificação opcional apenas o acorda mais cedo. Se a arquitetura exigir publicar no broker, faça a passagem com transactional outbox. Inserir no banco e enviar à fila em chamadas independentes reintroduz o intervalo em que uma operação funciona e a outra falha.
Quando o contrato oferece uma versão, rejeite retratos antigos
Uma possibilidade é usar uma versão crescente por recurso, quando o contrato oferece essa informação. Se ela ordena retratos completos daquele recurso, armazene a versão junto da projeção e só atualize quando a recebida for maior. A comparação precisa fazer parte da escrita, para que dois workers não leiam o mesmo valor antigo e depois sobrescrevam um ao outro.
O SQL a seguir depende de uma versão crescente fornecida pela origem. provider_version é o nome usado no modelo local para essa informação. Se a integração não oferece essa garantia, a comparação não deve ser adotada. A tabela subscription_projection deve ter uma restrição única nas quatro colunas da identidade usada pelo ON CONFLICT:
INSERT INTO subscription_projection (
provider,
source_account,
environment,
resource_id,
provider_version,
state
)
VALUES (
:provider,
:source_account,
:environment,
:resource_id,
:provider_version,
CAST(:state AS jsonb)
)
ON CONFLICT (provider, source_account, environment, resource_id)
DO UPDATE SET
provider_version = EXCLUDED.provider_version,
state = EXCLUDED.state
WHERE EXCLUDED.provider_version >
subscription_projection.provider_version
RETURNING resource_id;
Uma versão menor não altera a projeção. A mesma versão com conteúdo diferente merece investigação conforme o contrato. Para exclusões, preserve uma marca de remoção com a versão correspondente, em vez de apagar toda a evidência e permitir que um retrato antigo recrie o recurso. E atenção à condição inicial: se os eventos contêm incrementos ou alterações parciais, pular versões pode perder operações; esse caso exige detectar lacunas e recuperar os fatos ausentes.
Timestamp de recebimento, relógio do seu servidor e ordem alfabética do ID não substituem uma versão garantida pelo provedor. Um horário informado na origem também precisa de garantias: se duas mudanças podem receber o mesmo valor, ele não permite decidir qual veio depois. Inventar um desempate por ID produz uma sequência repetível, mas não comprova a ordem das mudanças.
Sem versão ordenável, trate o webhook como aviso para reconciliar
Se a API permite consultar o recurso atual, o evento pode ser tratado como um aviso de que há algo para conferir. O consumidor busca esse estado pela API autenticada e atualiza a cópia local. Na projeção da assinatura, um webhook antigo passa a provocar uma nova consulta ao recurso, em vez de impor à aplicação o retrato de quando ele estava ativo. A liberação de acesso continua seguindo as regras do produto.
Isso ainda precisa de controle de concorrência. Um worker pode consultar antes do cancelamento, demorar, e gravar depois de outro que já buscou o estado cancelado. Serializar apenas o UPDATE não resolve essa corrida, porque o primeiro resultado já nasceu antigo. O trecho que precisa de uma única execução por recurso inclui a consulta remota e a gravação local.
O fluxo abaixo é pseudocódigo. As funções representam responsabilidades da infraestrutura, não uma biblioteca pronta:
processarSerialmente(chaveDoRecurso, async () => {
const atual = await consultarRecursoAtual(chaveDoRecurso);
await transacao(async (tx) => {
await atualizarProjecao(tx, chaveDoRecurso, atual);
await marcarEventoConcluido(tx, chaveDoEvento);
});
});
A chave deve incluir provedor, conta, ambiente e recurso. Uma fila particionada por essa chave, com processamento sequencial dentro do grupo, é uma possibilidade. Um mutex em memória protege somente aquela instância. Em mais de uma instância, é necessário um mecanismo compartilhado que mantenha a exclusividade. Se ele usa reservas com prazo (leases), precisa também impedir a gravação de um worker antigo que continue rodando após perder a posse, por exemplo com um token de geração conferido na escrita.
Evite manter uma transação de banco aberta durante a chamada de rede apenas para conseguir essa exclusividade: a espera segura locks e conexões. O mecanismo de processamento pode abranger a consulta, enquanto a transação de atualização permanece curta. Qualquer retomada após falha precisa respeitar a mesma identidade do recurso.
Essa abordagem entrega convergência, não uma transação entre o provedor e o seu banco. O recurso pode mudar logo depois da consulta; outro evento ou uma reconciliação posterior precisa trazer a mudança. Respostas de erro, indisponibilidade e acesso negado não podem virar automaticamente “assinatura cancelada”. Até um 404 precisa ser interpretado conforme o contrato da API e o contexto da conta consultada.
Confirmação de recebimento, conclusão e efeito externo são etapas diferentes
O esquema reduzido da inbox não implementa a tomada de trabalho pelos workers. Em produção, inclua um mecanismo de reserva de trabalho (claim) que evite disputa indevida, recuperação de execuções interrompidas, contador de tentativas, próxima tentativa e motivo da última falha. Atualizar a projeção e marcar o evento concluído na mesma transação permite refazer a tentativa se qualquer uma das escritas falhar.
Se o consumidor também envia um e-mail ou chama outra API, essa transação local não cobre o efeito externo. Registre a ação de saída de forma recuperável e use uma chave estável para a ação, como conta, fatura e tipo de recibo. Um timeout depois do envio pode deixar o resultado ambíguo; a estratégia de retries precisa conhecer essa identidade. Não use somente o estado atual da assinatura para decidir se uma fatura histórica já recebeu recibo.
A expressão “processar exatamente uma vez” esconde essas fronteiras. É possível impedir uma segunda aplicação de determinada mudança local e ainda repetir uma chamada externa após uma queda. Documente qual efeito está protegido, onde a decisão é persistida e como a recuperação consulta o resultado anterior.
O webhook que nunca chegou também precisa de um caminho
Reentregas automáticas, quando existem, têm limites. Uma indisponibilidade prolongada, um filtro de eventos configurado incorretamente ou uma falha no consumidor pode deixar uma projeção antiga sem outra notificação para corrigi-la. Tenha um reconciliador periódico para os recursos relevantes, usando as consultas disponíveis na integração, respeitando cotas e seguindo o mesmo caminho de atualização protegido contra concorrência.
Reconciliar estado atual não recupera automaticamente todos os fatos históricos. Se a aplicação precisa registrar cada pagamento, use as APIs de listagem ou recuperação de eventos e faturas disponíveis, com cursores, checkpoints e deduplicação por identidade. Guarde um intervalo de sobreposição entre varreduras quando o contrato exigir. A política depende das garantias de retenção e consulta do provedor.
Observe idade do pendente mais antigo, atraso entre recebimento e aplicação, falhas por recurso, divergências encontradas na reconciliação e ações externas ainda abertas. Eventos esgotados devem permanecer inspecionáveis e permitir reprocessamento controlado. Payloads podem conter dados pessoais: conserve o necessário para recuperação, com acesso e retenção definidos, sem despejar corpos completos em logs.
Para o serviço de assinaturas, o critério de sucesso é concreto: trocar a ordem de chegada ou repetir uma entrega não pode restaurar o retrato ativo antigo de uma assinatura já cancelada. Com os dados sincronizados, a aplicação deve aplicar sua regra de encerramento imediato do acesso. Esse critério orienta o desenho e os testes do sistema, inclusive quando a fila retoma uma execução interrompida ou o reconciliador encontra uma divergência.

