Confiabilidade6 de agosto de 20267 min de leitura

Agendamento duplicado: como uma pessoa de verdade vê um retry de webhook

Debates sobre idempotência costumam acabar numa linha duplicada no banco. Com webhooks de agendamento, acabam em duas pessoas no mesmo horário das 10:00.

UM AGENDAMENTO, ENTREGUE DUAS VEZESinvitee.created200 demorouseu handlergrava o horárioagenda10:00 ocupadonova tentativamesmo agendamentoagenda10:00 ocupado 2 vezesduas pessoas,um horárioo conflito não está no seu banco de dados. está na manhã de alguém.
Um agendamento, duas entregas, dois eventos na agenda. A duplicata não está numa tabela, está na manhã de alguém.

A maior parte do que se escreve sobre idempotência de webhooks, incluindo o que nós escrevemos, argumenta do banco de dados para fora. Você ganha uma linha duplicada, um segundo recibo, um contador dobrado. Problemas reais, mas abstratos. Quem lê concorda com a cabeça e segue em frente.

Integrações de agendamento são onde o argumento deixa de ser abstrato. A duplicata não é uma linha. São duas pessoas numa videochamada às 10:00, cada uma achando que o horário é dela, e uma delas tirou a manhã de folga para isso.

TL;DR

  • Webhooks de agendamento têm timeouts folgados, o que passa uma sensação de segurança
  • A consequência de uma nova tentativa (retry) é um conflito no mundo real que um humano precisa resolver, não uma linha que você pode limpar
  • O Calendly assina com Calendly-Webhook-Signature, um par de timestamp e assinatura, HMAC-SHA256 sobre timestamp.body
  • Deduplique pela URI ou pelo UUID do próprio agendamento, não pelo conteúdo do payload, porque um reagendamento se parece muito com um agendamento novo
  • Cancelamentos e reagendamentos que chegam fora de ordem são o caso que de fato corrompe uma agenda

Por que esta categoria parece segura e não é

Quase tudo o que torna assustadoras as outras categorias de webhook não existe aqui. Não há um prazo de resposta de cinco segundos como o da Shopify. Nenhuma inscrição é excluída depois de uma tarde ruim. O volume de agendamentos da maioria dos produtos é baixo o bastante para você nunca bater num rate limit.

Então o handler é escrito rápido, testado com um agendamento e colocado em produção.

A exposição está do outro lado. Quando um webhook de pagamento é processado duas vezes, você percebe nos seus próprios dados e corrige antes que alguém de fora seja afetado. Quando um webhook de agendamento é processado duas vezes, a segunda cópia fica visível na hora para um cliente e, quando você descobre, o estrago já é um conflito de agenda, não uma inconsistência de dados.

UM AGENDAMENTO, ENTREGUE DUAS VEZESinvitee.created200 demorouseu handlergrava o horárioagenda10:00 ocupadonova tentativamesmo agendamentoagenda10:00 ocupado 2 vezesduas pessoas,um horárioo conflito não está no seu banco de dados. está na manhã de alguém.

A sequência exata

Um convidado reserva o horário das 10:00. O Calendly dispara invitee.created.

Seu handler grava o horário, depois chama seu provedor de agenda para criar o evento, depois envia um e-mail de confirmação. Naquela manhã, a chamada à agenda está lenta. O processo todo demora o suficiente para que a entrega seja registrada como falha.

O Calendly tenta de novo. Seu handler roda de novo. Nada nele sabe que a primeira execução terminou, porque ela só terminou depois que a resposta já tinha se perdido. Dois eventos na agenda, dois e-mails de confirmação e um horário que sua lógica de disponibilidade agora acredita estar comprometido duas vezes.

Cada passo individual funcionou. O handler não tem nenhum bug que um teste unitário encontraria.

Deduplique pelo agendamento, não pelo payload

O instinto óbvio é calcular o hash do payload e tratar hashes iguais como duplicatas.

Use o identificador que o provedor atribui ao próprio agendamento. Os payloads do Calendly trazem uma URI que identifica de forma única o convidado e o evento agendado. O Cal.com e o Acuity têm equivalentes. Esse identificador é estável entre as novas tentativas da mesma entrega e diferente para um agendamento de fato diferente.

CREATE TABLE processed_bookings (
  booking_uri TEXT NOT NULL,
  event_type  TEXT NOT NULL,
  occurred_at TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (booking_uri, event_type)
);
const { rowCount } = await db.query(
  `INSERT INTO processed_bookings (booking_uri, event_type, occurred_at)
   VALUES ($1, $2, $3) ON CONFLICT (booking_uri, event_type) DO NOTHING`,
  [body.payload.uri, body.event, body.created_at],
);
if (rowCount === 0) return new Response(null, { status: 200 });

Repare na chave composta (booking_uri, event_type), em vez da URI sozinha. O mesmo agendamento produz legitimamente invitee.created e, depois, invitee.canceled, e você quer os dois, exatamente uma vez cada.

Cancelamentos fora de ordem são o bug pior

Duplicatas criam um conflito que alguém percebe na hora. Falhas de ordem criam uma agenda silenciosamente errada.

Alguém reserva o horário das 10:00 e cancela quatro minutos depois. A entrega de invitee.created falha e é tentada de novo. A entrega de invitee.canceled dá certo primeiro. Seu handler cancela um agendamento que ainda não existe, registra um aviso e segue em frente. Aí a nova tentativa chega e cria o agendamento.

Agora o horário está bloqueado por uma reunião a que ninguém vai, e nenhum erro foi gerado em lugar nenhum. A deduplicação é irrelevante: nenhum dos dois eventos chegou duas vezes. Eles chegaram na ordem errada.

A correção é a mesma condição monotônica usada para assinaturas e registros de CRM, aplicada ao timestamp do próprio agendamento, e não ao horário em que o evento chegou até você:

UPDATE bookings
SET status = $1, updated_at = $2
WHERE booking_uri = $3
  AND updated_at < $2;

Para uma máquina de estados tão pequena, uma verificação explícita de transição é melhor ainda. Um agendamento que já está canceled deve se recusar a virar active por causa de um evento de criação que chegou atrasado, e essa recusa deve gerar um log bem visível, não silencioso. O tratamento geral está em Você recebeu o mesmo webhook duas vezes.

Verificação de assinatura

O Calendly envia um cabeçalho Calendly-Webhook-Signature com um timestamp e uma assinatura, separados por vírgula. O payload assinado é o timestamp, um ponto e o corpo bruto da requisição, com o hash HMAC-SHA256 calculado com a chave de assinatura que você recebeu quando a inscrição foi criada.

Se esse formato parece familiar, é a mesma construção que a Stripe usa (a referência de provedores de webhook mostra quais provedores a compartilham), e ela traz as mesmas duas exigências: calcular o hash sobre os bytes brutos, não sobre um objeto serializado de novo, e comparar em tempo constante. Detalhes de cada provedor em Como verificar assinaturas de webhook da Stripe, do GitHub e da Shopify.

Rejeite requisições sem o cabeçalho de assinatura, em vez de aceitá-las por padrão. Um endpoint de agendamento que aceita POSTs sem assinatura deixa qualquer pessoa na internet lotar sua agenda.

Faça da agenda a restrição

A correção que mais se sustenta nesta categoria nem está no handler de webhook. É uma restrição de unicidade no nível do recurso:

CREATE UNIQUE INDEX one_booking_per_slot
  ON bookings (resource_id, starts_at)
  WHERE status = 'active';

Agora uma entrega duplicada não consegue produzir um agendamento duplicado mesmo que todas as outras camadas falhem, porque o banco de dados recusa. O handler captura a violação da restrição e a trata como duplicata, não como erro.

Vale fazer isso mesmo com a idempotência correta, porque a garantia passa de "nosso handler é cuidadoso" para "o schema torna isso impossível". São forças diferentes, e para algo que o cliente sente diretamente você quer a segunda.

Onde o AnyHook entra

A nova tentativa é um comportamento de transporte, e seu handler viu o evento duas vezes porque o recebimento da primeira entrega não pôde ser confirmado a tempo.

Aponte a inscrição de webhook para in.anyhook.net/you/scheduling. O AnyHook armazena o evento na edge (o Cloudflare Worker do AnyHook) e confirma o recebimento em cerca de um segundo, então uma API de agenda lenta do seu lado nunca produz uma entrega com falha, o que significa que a nova tentativa do provedor nunca dispara e essa duplicata nunca chega ao seu handler.

  • A entrega para seu endpoint é tentada de novo com backoff exponencial, com intervalos que dão espaço a um sistema em recuperação, e não pressão
  • O AnyHook armazena cada evento de agendamento antes da entrega, então a pergunta "recebemos o cancelamento?" tem resposta num log, em vez de ser deduzida de uma agenda que pode estar errada
  • O replay (um novo envio de um evento armazenado, feito por você) de um intervalo de tempo roda de novo uma janela quebrada contra um handler já corrigido, o que, para agendamentos, significa consertar a agenda em vez de mandar e-mail para os clientes reagendarem
  • Alertas de falha com 1, 5 e 20 falhas consecutivas, o que, nesta categoria, é a diferença entre descobrir pelo seu monitoramento e descobrir pela pessoa que apareceu

Seu handler ainda precisa da chave de deduplicação, da condição de versão e, de preferência, do índice único. Um relay de webhooks (um serviço que recebe, guarda e encaminha seus webhooks) elimina a nova tentativa do provedor, não a exigência de estar correto quando uma acontecer.

Conclusão

Agendamento é a categoria em que a correção dos webhooks deixa de ser uma preocupação de engenharia e passa a ser algo que o cliente vê. Os timeouts são folgados o bastante para que nada obrigue você a pensar nisso, até o momento em que duas pessoas estão no mesmo horário.

Deduplique pelo identificador do agendamento e pelo tipo de evento juntos. Proteja as escritas com o timestamp do provedor, não com o seu. Coloque um índice único no horário para que o schema pegue o que o handler deixar passar. E trate um cancelamento atrasado do mesmo jeito que trataria um reembolso atrasado, porque uma agenda silenciosamente errada custa mais para consertar do que uma linha errada.

Todos os posts6 de agosto de 2026 · 7 min

Pare de perder webhooks.

Troque uma URL. Tenha novas tentativas, um log de eventos e replay com um clique. Ou veja antes: crie uma conta, rode a simulação de queda de 30 segundos e veja um evento sobreviver ao seu servidor fora do ar. Não precisa de servidor.