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 sobretimestamp.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.
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.