Webhooks de CRM parecem os fáceis. Nenhum dinheiro se move, nenhuma inscrição é excluída se você demora. Um contato mudou, vá atualizar sua cópia.
Aí um cliente avisa que, na sua aplicação, a fase do ciclo de vida dele diz Lead e, no HubSpot, diz Customer, e isso está errado há duas semanas, e nenhum evento envolvido foi processado duas vezes.
Webhooks de registros de negócio falham de um jeito diferente dos webhooks de pagamento. O volume é dominado por atualizações, não por eventos novos, o mesmo objeto muda muitas vezes numa janela curta, e as garantias de ordem são mais fracas do que qualquer pessoa lendo o payload suporia.
TL;DR
- "O HubSpot não garante que você vai receber essas notificações na ordem em que ocorreram"
- As notificações chegam como arrays, não como objetos únicos. Os lotes podem ter até 100
- O prazo de resposta é de 5 segundos, para o lote inteiro. Os outros remetentes estão na referência de provedores de webhook
- Notificações com falha são tentadas de novo até 10 vezes ao longo de 24 horas, o que alarga a janela de reordenação
- A documentação diz que o
eventId"não tem garantia de ser único", então ele é a chave de idempotência errada - Ordene por
occurredAt. Deduplique por uma chave composta. Nunca ordene pelo horário de chegada
O campo que parece ser a resposta
Para quem vem da Stripe, o reflexo é achar o ID do evento, colocar um índice único nele e seguir em frente. O payload do HubSpot tem o eventId bem ali.
A documentação diz: eventId é "O ID do evento que disparou esta notificação. Não há garantia de que este valor seja único."
É um aviso incomumente direto, e é fácil passar por ele sem ler. Um índice único em eventId vai parecer correto nos testes e, em algum momento em produção, vai rejeitar um evento de que você precisava ou aceitar um que você já tratou. Nenhuma das duas falhas se anuncia.
Os dois campos em que você pode confiar são occurredAt, "Quando este evento ocorreu, como um timestamp em milissegundos", e attemptNumber, "Começando em 0, o número desta tentativa de notificar seu serviço sobre este evento."
O attemptNumber é o diagnóstico útil. Qualquer valor acima de 0 significa que esta é uma nova tentativa (retry), o que significa que seu endpoint falhou antes, o que significa que os eventos agora estão chegando fora da ordem em que aconteceram.
Os lotes mudam o formato do handler
O HubSpot não envia uma notificação por requisição: "você deve esperar receber um array de objetos numa única requisição. O tamanho do lote pode variar, mas será de menos de 100 notificações."
Três consequências.
Seu handler é um loop, não uma função. O prazo de 5 segundos cobre o array inteiro. Uma ida e volta ao banco de dados por item, de 30 ms, sozinha já estoura o prazo por volta dos 160 itens e, com outro trabalho junto a cada item, pode estourá-lo antes de o array chegar a 100.
Falha parcial não tem vocabulário em HTTP. Se o item 40 de 90 lançar uma exceção, você não tem como dizer ao HubSpot "os 39 primeiros chegaram". Você responde um status para o lote inteiro. Responda algo diferente de 2xx e os 90 voltam na nova tentativa; responda 200 e os 50 que você não alcançou se perdem.
Isso leva à mesma conclusão de sempre: grave o array inteiro primeiro, responda 200, processe depois. A gravação do lote é um insert, não 90, o que também resolve o problema de tempo.
export async function POST(req: Request) {
const raw = await req.text();
if (!verifyHubSpotSignature(raw, req.headers)) {
return new Response("unauthorized", { status: 401 });
}
const notifications = JSON.parse(raw); // always an array
await db.insertBatch(notifications); // one durable write
return new Response(null, { status: 200 }); // inside 5s regardless of size
}
Por que a ordem quebra e por que deduplicar não resolve
Um vendedor edita um negócio quatro vezes em noventa segundos. Quatro notificações. Seu endpoint fica instável por um momento e rejeita a segunda. O HubSpot tenta de novo, "até 10 vezes", com as novas tentativas "distribuídas ao longo das próximas 24 horas, com intervalos variados entre as requisições."
Então a notificação dois chega depois das notificações três e quatro. Seu handler a aplica. A fase do negócio agora é a que era na segunda edição, e vai continuar assim até alguém mexer no registro de novo.
Nenhuma notificação foi processada duas vezes. A deduplicação funcionou perfeitamente. Os dados estão errados mesmo assim.
Esta é a distinção que importa: a idempotência protege contra processar o mesmo evento duas vezes. Nada nela protege contra aplicar um estado mais antigo por cima de um mais novo. São falhas diferentes, com correções diferentes, e fluxos de CRM produzem a segunda muito mais vezes do que fluxos de pagamento, porque o mesmo objeto é atualizado repetidamente.
A correção é uma condição monotônica na própria escrita.
UPDATE contacts
SET lifecycle_stage = $1, hs_updated_at = $2
WHERE hubspot_id = $3
AND hs_updated_at < $2; -- refuse to apply stale state
Use occurredAt em $2, e não seu próprio NOW(). Seu relógio registra quando você recebeu a notificação, e a ordem de recebimento é exatamente o que não é confiável. O tratamento mais amplo, incluindo o que fazer com máquinas de estados com invariantes reais, está em Você recebeu o mesmo webhook duas vezes.
Uma chave de deduplicação que se sustenta
Como o eventId está fora de cogitação, monte uma chave composta com campos que, juntos, identificam uma mudança:
CREATE TABLE processed_hubspot (
object_id BIGINT NOT NULL,
subscription_type TEXT NOT NULL,
occurred_at BIGINT NOT NULL,
PRIMARY KEY (object_id, subscription_type, occurred_at)
);
Um objeto, um tipo de mudança, um milissegundo. Duas edições genuinamente distintas no mesmo campo e no mesmo milissegundo colidiriam, um risco real, mas desprezível, para edições de CRM feitas por pessoas, e um risco real para importações em massa. Para inscrições de mudança de propriedade, adicione propertyName à chave: o subscriptionType é o mesmo seja qual for a propriedade que mudou, então, sem ela, duas propriedades diferentes alteradas no mesmo milissegundo também colidem.
Vale dizer isso com todas as letras, porque é um degrau abaixo do que a Stripe oferece. Com um ID de evento único emitido pelo provedor, a deduplicação é exata. Com uma chave composta, é uma heurística muito boa. Registre a escolha ao lado da definição da tabela, para que a próxima pessoa saiba que ela foi deliberada.
Importações em massa são o teste de estresse
Um cliente que importa 50.000 contatos gera 50.000 notificações numa janela curta, agrupadas em arrays. É aí que tudo o que foi dito acima deixa de ser teórico ao mesmo tempo: os lotes estão no tamanho máximo, seu banco de dados está no pico de carga, o prazo de 5 segundos está no ponto mais apertado, e qualquer falha produz novas tentativas que chegam horas depois, intercaladas com edições ao vivo.
Se você só testa com eventos avulsos feitos à mão, não testou o caso que quebra. Envie para si mesmo um lote de 100 com os timestamps embaralhados de propósito e confira se o estado final é o do occurredAt mais novo, e não o da notificação que chegou por último.
Onde o AnyHook entra
Nada do problema de ordem é de transporte. Se o HubSpot envia a notificação dois depois da notificação quatro, nenhum relay de webhooks (um serviço que recebe, guarda e encaminha seus webhooks) consegue desfazer o envio, e qualquer produto que diga o contrário está prometendo demais.
O que um relay muda é tudo o que está em volta.
Aponte o HubSpot para in.anyhook.net/you/hubspot e o AnyHook responde 200 em cerca de um segundo, seja qual for o tamanho do lote, então o prazo de 5 segundos deixa de limitar o jeito como você processa. A entrega para seu endpoint segue o cronograma de novas tentativas do AnyHook, não o do HubSpot, então uma queda breve não alarga a janela de reordenação em 24 horas.
Além disso:
- O AnyHook armazena cada lote com cabeçalhos e corpo antes de tentar a entrega, então "o que recebemos de fato, e em que ordem" tem resposta num log, em vez de ser reconstruído a partir do estado atual do seu banco de dados
- Fazer o replay (um novo envio de um evento armazenado, feito por você) de todos os eventos com falha num intervalo de tempo permite rodar de novo o que uma importação perdeu contra um handler já corrigido, em vez de pedir ao cliente que importe tudo de novo
- Um
attemptNumberacima de 0 deixa de ser sua única pista de que algo deu errado, porque o AnyHook conta as falhas de entrega e envia alertas com 1, 5 e 20
Seu handler ainda precisa da condição de versão. Essa parte é sua.
Conclusão
A categoria de CRM recompensa reflexos diferentes dos da categoria de pagamentos. Leia a documentação dos campos antes de indexar qualquer coisa, porque o HubSpot é incomumente honesto ao dizer que o ID de evento dele não vai aguentar o peso que você quer pôr nele. Ordene por occurredAt, deduplique por uma chave composta escolhida de propósito e proteja cada escrita contra estado desatualizado.
Se seu handler hoje faz UPDATE ... WHERE id = $1 sem nenhuma condição de timestamp, a questão não é se ele já gravou dados desatualizados. A questão é se alguém já percebeu.