Idempotência e ordem
Duas garantias que a entrega de webhook não dá, e que a sua aplicação precisa cobrir:
- Cada evento chega pelo menos uma vez, não exatamente uma vez.
- Os eventos chegam em ordem qualquer.
Um receptor que assume o contrário funciona no dia do teste e falha no dia do pico.
Deduplicar por X-Event-Id
O X-Event-Id (evt_…, o mesmo valor do id no corpo e do header Idempotency-Key) identifica o
evento. Ele é estável: as 6 tentativas da entrega, um reenvio manual meses depois e a cópia que
vai para o postback_url carregam todos o mesmo evt_….
Já o X-Delivery-Id (whd_…) identifica a tentativa, e muda a cada uma. Deduplicar por ele
não deduplica nada.
A dedupe que aguenta concorrência é uma restrição de unicidade no seu banco, não um if na memória
do processo:
CREATE TABLE eventos_recebidos (
event_id text PRIMARY KEY,
recebido_em timestamptz NOT NULL DEFAULT now()
);
Grave o event_id na mesma transação do efeito que o evento causa (creditar o pedido, liberar
o acesso, mandar o e-mail). Violação de chave primária significa "já processei", e a resposta certa
é 2xx, não erro: o emissor não tem como saber que foi duplicata, e responder erro só gera mais
retentativas.
Gravar primeiro e processar depois, em transações separadas, troca o problema de lugar: se o processo morrer no meio, o evento fica marcado como recebido sem ter tido efeito, e a retentativa vai ser descartada.
Quando é que uma duplicata acontece de verdade:
- a sua resposta
2xxse perdeu na rede e a tentativa seguinte saiu; - você pediu um reenvio manual de algo que já tinha processado;
- a cobrança tem
postback_urle a conta tem endpoint assinando o tipo: são duas entregas do mesmo evento, com o mesmoevt_….
Ordem não é garantida
Cada entrega tem a sua própria agenda de retentativas. Basta a primeira falhar uma vez para o evento seguinte, entregue de primeira, chegar antes dela. Com a agenda de retries, a distância entre dois eventos pode passar de 12 h.
E há um caso em que a inversão não é acidente nenhum: uma cobrança expirada pode ser paga. O
prazo de expiração é um pedido ao banco, não uma garantia, e um pagamento que entra depois dele é
legítimo. Você recebe transaction.expired e, mais tarde, transaction.paid da mesma cobrança.
Não trate expired como estado final.
Duas defesas simples:
- Use o
created_atdo envelope, não a hora em que você recebeu. Ele é o instante em que o evento aconteceu do nosso lado. - Não deixe o evento retroceder o seu estado. Guarde o
created_atdo último evento aplicado por recurso e descarte o que for mais antigo. Cobrança que você já marcou como paga não volta a pendente porque umtransaction.expiredatrasado apareceu.
Se a sua regra de negócio depende de uma sequência exata, ela não deve depender do webhook. O webhook é o gatilho; a verdade é a consulta.
Reconciliar pela consulta
O webhook diz "olhe isto agora". Quem diz "é assim que está" é a API.
- Para o estado atual de um recurso, consulte
Consulta uma cobrançaouConsulta um saque. É o desempate quando dois eventos discordam, e é o que você usa depois de uma janela em que o seu receptor esteve fora do ar. - Para varrer o que aconteceu num período, use o feed de eventos da conta
(
Lista o feed de eventos do seller, eBusca um evento do feedpara um id específico). Serve tanto para conferir se um evento existiu quanto para reprocessar do seu lado sem depender de reenvio. - Um passe periódico sobre as cobranças que ainda estão
pendingdo seu lado fecha o buraco de qualquer evento que nunca chegou. Rodar de hora em hora já resolve, e é barato porque a lista é pequena por definição.
No ambiente de teste existem gatilhos para exercitar exatamente isso: cobrança
que entrega o mesmo evento duas vezes, cobrança que é paga sem nenhum webhook e cobrança que entrega
transaction.expired antes de transaction.paid. Vale rodar os três contra o seu receptor antes
de ir para live.
Tipo e campo desconhecidos
Tipo de evento novo e campo novo em data.object são mudanças aditivas e podem aparecer sem aviso.
Um receptor que responde 400 para o que não conhece transforma isso em falha de entrega, retry e,
no limite, endpoint desativado.
Ignore o que não reconhece e responda 2xx. Se você quer só alguns tipos, assine só eles no
endpoint em vez de filtrar com erro no seu lado.