Pular para o conteúdo
LeztyPay docs
Painel

Idempotência e ordem

Duas garantias que a entrega de webhook não dá, e que a sua aplicação precisa cobrir:

  1. Cada evento chega pelo menos uma vez, não exatamente uma vez.
  2. 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 2xx se perdeu na rede e a tentativa seguinte saiu;
  • você pediu um reenvio manual de algo que já tinha processado;
  • a cobrança tem postback_url e a conta tem endpoint assinando o tipo: são duas entregas do mesmo evento, com o mesmo evt_….

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_at do 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_at do ú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 um transaction.expired atrasado 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ça ou Consulta 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, e Busca um evento do feed para 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 pending do 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.