Pular para o conteúdo

Receba eventos por webhook

Um webhook é uma mensagem que enviamos ao seu servidor quando algo acontece: um e-mail sai, uma resposta chega, uma campanha pausa. Comece pelo menor deles, um teste.

Peça ao dono para abrir Configurações › Webhook e apertar Enviar evento de teste. Seu servidor recebe um POST com este corpo:

Exemplo de corpo. O ID e a hora são inventados.
{
  "id": "9d1c4b52-3f6e-4a7b-8c21-5e0f7a6b3d19",
  "type": "webhook.test",
  "seq": 0,
  "generation": "70000000-7000-4700-8700-700000000000",
  "timestamp": "2026-10-02T14:30:00.000Z",
  "data": {
    "test": true,
    "message": "This is a test from colding."
  }
}

id é o ID desta entrega. type é webhook.test e seq é 0, porque um teste não é um evento: os eventos de verdade começam em 1. generation é o ID da série de números de eventos em que a lista está; a seção sobre restauração, mais abaixo, explica. data traz uma frase fixa em inglês.

Antes de confiar num POST, confira se ele veio de nós. verifyWebhook recebe três coisas: payload, o corpo exatamente como chegou; signature, o valor do cabeçalho x-colding-signature; e secret, o segredo de assinatura whsec_ que o dono copiou ao salvar o webhook. Ela devolve o evento já lido ou lança um erro.

import { verifyWebhook } from "colding";

async function receive(request: Request): Promise<Response> {
  const event = await verifyWebhook({
    payload: await request.text(),
    signature: request.headers.get("x-colding-signature"),
    secret: "whsec_the_secret_you_were_shown",
  });
  console.log(event.id, event.type);
  return new Response("ok");
}

Leia o corpo como texto e confira antes que qualquer coisa o interprete. Um framework que lê o JSON e o escreve de novo muda os bytes, e a conferência falha com no_match.

Veja o que pode dar errado

Configure

O dono configura o webhook em Configurações › Webhook: um endereço https que seu servidor atende, na porta 443 (a padrão) ou 8443, e os eventos a enviar. O segredo de assinatura aparece uma única vez, quando o webhook é salvo ou o segredo é trocado. Guarde-o onde seu servidor consiga ler.

Uma chave não pode salvar, alterar nem testar um webhook, e não existe solicitação para isso. Se você é um agente, peça ao dono que configure e que ponha o segredo onde seu servidor lê.

Seu servidor responde a cada POST com um status 2xx, assim que recebe o corpo. Faça o trabalho demorado depois de responder.

Como é uma entrega

Uma entrega é um POST com um corpo JSON e três cabeçalhos.

O corpo é { id, type, seq, generation, timestamp, data }. id é o ID da entrega. type é o tipo do evento. seq é o número do evento na lista de eventos, o mesmo que events.list mostra. generation é a série de números a que o seq pertence, o mesmo valor que events.list responde. timestamp é a hora em que o evento foi registrado, não a desta tentativa. data é pequeno: IDs e alguns fatos, como o endereço de um destinatário ou um link clicado. Nunca traz o assunto nem o texto de uma mensagem.

Os cabeçalhos:

x-colding-delivery
O ID da entrega, igual ao id do corpo e igual em toda tentativa.
x-colding-event
O tipo do evento, igual a type. Leia-o para direcionar um POST antes de abrir o corpo.
x-colding-signature
A prova, no formato t=…,v1=…. Passe-a para verifyWebhook.

Uma entrega pode chegar duas vezes. Guarde o id de cada entrega que você tratar e ignore a repetição.

Confira uma entrega

Quando verifyWebhook não consegue confiar numa entrega, ela lança um ColdingWebhookError. Responda 400 e não faça nada com o corpo, a não ser que o code seja bad_secret. O code diz o motivo:

missing_header
Não há cabeçalho de assinatura, ou ele está vazio. Só as nossas entregas trazem um.
bad_format
O cabeçalho não tem o formato t=…,v1=….
too_old
A entrega foi assinada há mais tempo do que a janela permite. Alguém pode estar repetindo uma entrega capturada.
too_new
Ela foi assinada mais à frente do que a janela permite. Confira o relógio do seu servidor.
no_match
Nenhuma assinatura confere. O segredo está errado ou o corpo mudou no caminho, como quando um framework lê o JSON e o escreve de novo.
bad_body
A assinatura está certa, mas o corpo não é JSON.
bad_secret
O segredo que você passou está vazio. O problema é a sua configuração, não a entrega, então responda 500.

Capture o erro, responda e siga em frente:

import { ColdingWebhookError, verifyWebhook } from "colding";

async function receive(request: Request, secret: string): Promise<Response> {
  try {
    const event = await verifyWebhook({
      payload: await request.text(),
      signature: request.headers.get("x-colding-signature"),
      secret,
    });
    console.log(event.id, event.type);
    return new Response("ok");
  } catch (error) {
    if (!(error instanceof ColdingWebhookError)) throw error;
    // bad_secret is your setup, not the delivery, so it answers 500.
    return new Response(error.code, { status: error.code === "bad_secret" ? 500 : 400 });
  }
}

A janela é de 5 minutos para cada lado, a menos que você a mude. Cada tentativa é assinada de novo, então uma nova tentativa que chega depois não é recusada por ser antiga. Se o relógio do seu servidor está errado, corrija o relógio primeiro. Se não der, toleranceSeconds alarga a janela:

const event = await verifyWebhook({ payload, signature, secret, toleranceSeconds: 600 });

Envie um evento de teste

O dono aperta Enviar evento de teste em Configurações › Webhook. Assim você vê que seu servidor recebe um POST e passa na conferência, antes de um evento de verdade fazer isso por você.

  • Ele é assinado como uma entrega de verdade, então o código que confere uma confere este também.
  • O corpo é o do início desta página: type é webhook.test, seq é 0 e data traz test: true e uma mensagem.
  • Ele não é um evento. events.list nunca o mostra, e um webhook não pode se inscrever nele.
  • Ele é enviado uma vez só. Se seu servidor falhar, não tentamos de novo, e ele não conta para desligar o webhook. Aperte o botão outra vez.
  • Ele sai mesmo com o webhook desligado, e não o liga de novo.
  • Um workspace pode enviar 10 eventos de teste por hora. Passou disso, Enviar evento de teste é recusado até a hora acabar.
  • O resultado fica na lista de entregas, marcado como webhook.test, com a resposta do seu servidor. Se seu servidor não viu nada, depois de 5 minutos a linha diz que o teste não chegou a sair. Envie um teste novo.

Novas tentativas e desligamento

Uma resposta 2xx quer dizer que você recebeu. Não seguimos redirecionamentos, então um 3xx encerra a entrega na hora. Um 4xx também, exceto 408, 425 e 429, que pedem para esperar e tentar de novo.

O resto é tentado de novo, até 8 tentativas no total, contando a primeira. As esperas entre elas são de 5 segundos, 5 minutos, 30 minutos, 2 horas, 5 horas, 10 horas e 10 horas: cerca de 28 horas da primeira tentativa à última. Se seu servidor manda um cabeçalho Retry-After pedindo mais, esperamos o que ele pediu, até 10 horas. Depois da última tentativa, a entrega fica como falha.

Seu servidor recebe no máximo 2 entregas ao mesmo tempo. Quando há mais para enviar, as outras esperam a vez e saem assim que uma termina. Esperar não é tentar: não gasta nenhuma das 8 tentativas e não conta para desligar o webhook.

Desligamos o webhook quando as entregas para ele falham durante 5 dias, sem nenhuma bem-sucedida no meio. Os dias contam a partir da primeira entrega que esgotou as tentativas, e uma entrega bem-sucedida recomeça a contagem. Depois de corrigir o servidor, o dono aperta Ligar de novo em Configurações › Webhook.

Quando desligamos o webhook, o dono recebe um alerta, e um e-mail, a menos que tenha desligado os e-mails de alerta. Em Configurações › Webhook aparece desde quando ele está desligado. Ligar de novo envia só os eventos novos. O que ele perdeu só sai de novo se o dono pedir: em Configurações › Webhook ele pode enviar os eventos escritos enquanto o webhook estava desligado ou enviar de novo todas as entregas que falharam. Você só pode reenviar eventos dos tipos que o webhook recebe naquele momento e que ainda estão na lista de eventos (24 horas). Cada clique conta nos 60 reenvios por hora.

Essa página também lista cada entrega, com o status, a resposta do seu servidor e, nas que falharam, o motivo. Ela guarda 30 dias e filtra por status e por tipo de evento. Mandar de novo envia agora, outra vez, uma entrega que falhou. Um workspace pode usar isso 60 vezes por hora. O reenvio só funciona enquanto o evento está na lista de eventos. Guardamos os eventos por pelo menos 24 horas. Depois disso, o botão pode não aparecer.

Quando seu servidor perdeu algo

Um webhook avisa seu servidor quando algo acontece. Se o servidor estiver fora do ar, o POST se perder ou todas as tentativas falharem, você ainda pode ler o evento na lista de eventos.

Na primeira chamada, deixe cursor de fora: você começa no evento mais antigo que ainda guardamos. Depois de tratar a página, guarde o resumeCursor que voltar. Depois de uma pausa, chame events.list com ele como cursor. Você recebe tudo o que veio depois desse ponto, do mais antigo ao mais novo. Uma chave com read consegue fazer isso.

const page = await colding.events.list({ cursor: lastCursor });
for (const event of page.items) console.log(event.seq, event.type);
// Save this after you handle the page, and pass it as `cursor` next time.
console.log(page.resumeCursor);

Consulte a lista de eventos pelo menos a cada 24 horas. É o tempo mínimo que guardamos cada evento. Depois disso, ele pode sair da lista. Se sair, uma entrega dele que falhou não pode ser reenviada.

Use seq para achar o evento de um POST na lista de eventos. O id do POST identifica a entrega, não o evento.

Um cursor só funciona com os types e a order de onde veio. order: "desc" lê de trás para a frente, a partir do evento mais novo. Um cursor também pertence a uma geração, e uma restauração o invalida.

Guarde o resumeCursor, não o nextCursor. O nextCursor serve para passar de página dentro de uma leitura e vem null numa página que não está cheia, então ele não diz de onde continuar. Toda página lida do mais antigo ao mais novo tem um resumeCursor, até a página vazia. Uma leitura com order: "desc" serve para consultar o histórico e não tem nenhum.

Depois que restauramos um backup

Depois que restauramos um backup

Às vezes recolocamos o banco de dados a partir de um backup. Os números dos eventos (seq) voltam então a um ponto mais antigo, e o mesmo número pode apontar para um evento diferente do que você viu antes.

Para isso não passar despercebido, cada página de events.list e cada entrega traz uma generation. É o ID de uma série de números de eventos. Um cursor ou um seq guardado só tem sentido dentro da geração de onde veio.

Depois de uma restauração, events.list responde a um cursor da geração antiga com o status 409, messageKey serverErrors.feedResetRequired, details.resetRequired com o valor true e a geração nova em details.generation. Um cursor de antes de existirem gerações recebe a mesma resposta. Nunca tratamos um cursor antigo como se ainda servisse.

Quando você receber essa resposta, ou vir uma generation nova numa entrega:

  1. Guarde a generation da última página ou entrega que você tratou. Uma diferente quer dizer que a numeração recomeçou.
  2. Pare de agir com base em eventos e descarte o cursor guardado.
  3. Confira os seus próprios registros para ver o que a restauração pode ter perdido: as pastas Enviados das suas caixas de e-mail, a lista de envios e as suas solicitações. Um envio ou uma aprovação que você não encontra não é permissão para fazer de novo. Se você não consegue saber o que aconteceu, pergunte ao dono.
  4. Só então chame events.list sem cursor. Depois de tratar a página, guarde o resumeCursor e a generation dela.
  5. Continue ignorando repetições pelo id. Uma entrega que esperou durante uma restauração mantém o id dela e o ID do evento, e leva a nova generation.

No SDK, um reset é um ColdingError com status 409. Este código lê a lista de novo, sem cursor, depois que você conferiu os seus registros:

let page;
try {
  page = await colding.events.list({ cursor: lastCursor });
} catch (error) {
  // 409: we restored from a backup, and this cursor is from before it.
  if (!(error instanceof ColdingError) || error.status !== 409) throw error;
  // Stop acting on events and check your mailboxes, the send list and your handoffs first.
  // A send or an approval you can't find is not permission to do it again.
  page = await colding.events.list();
}
// Save both after you handle the page. A different generation next time means the numbers started over.
console.log(page.generation, page.resumeCursor);

Um reset não conserta uma perda. Restauramos até o último ponto que temos. Um evento, ou um e-mail, de pouco antes desse ponto pode não existir no banco novo, e a lista de eventos não mostra o que ela não tem. Ler a lista de novo diz o que existe agora. Não diz o que está faltando.

Também pausamos as campanhas que estavam em andamento. Um e-mail que talvez estivesse a caminho fica marcado como unknown e nunca é enviado de novo. Antes de iniciar uma campanha outra vez, compare as pastas Enviados das suas caixas de e-mail com a lista de envios.

O reset não é uma das três proteções do envio. A chave da requisição HTTP, a intenção de envio e o worker que toma o e-mail para si funcionam como o guia de envio descreve, e o reset não substitui nenhuma delas.

Os eventos

O dono escolhe quais destes eventos o webhook recebe. Todos aparecem na lista de eventos, mesmo os que o webhook não recebe. Se um tipo foi adicionado depois que o webhook foi salvo, o dono precisa selecioná-lo para que o webhook passe a recebê-lo.

Com estes seis eventos, o agente pode esperar pelo webhook em vez de consultar a lista de eventos de tempos em tempos: campaign.finished, campaign.waiting_for_leads, mailbox.recovered, mailbox.address_confirmed, email_check.run_held e workspace.posture_changed.

send.sent
O servidor de e-mail por onde a mensagem saiu a aceitou: o da sua caixa ou, num endereço de remetente ou numa inbox de agente, o nosso. Isso não quer dizer que ela chegou na caixa de entrada.
send.failed
Uma mensagem não saiu. O evento diz por quê.
send.unknown
A conexão caiu na hora de entregar a mensagem. Ela pode ter chegado, então nunca mande de novo.
send.cancelled
Uma mensagem foi barrada logo antes de sair. O evento diz por quê.
send.bounced
O servidor do destinatário recusou a mensagem de vez.
send.delivered
O servidor do destinatário aceitou um e-mail que saiu pelos nossos servidores de e-mail. E-mail enviado de uma caixa que você conectou nunca dispara esse evento.
send.deferred
O servidor do destinatário pediu para esperar, e o nosso servidor de e-mail tenta de novo sozinho. Não envie o e-mail outra vez.
send.opened
Uma pessoa abriu a mensagem pela primeira vez.
send.clicked
Uma pessoa clicou num link da mensagem pela primeira vez.
handoff.completed
Uma pessoa terminou o que um agente pediu.
mailbox.failed
Não conseguimos ler uma caixa de e-mail. O evento diz por quê.
mailbox.warmup_health_changed
A saúde do aquecimento de uma caixa mudou, ou a quarentena ou o bloqueio dela ganhou uma nova data de fim.
message.received
Chegou uma mensagem nova numa caixa de e-mail.
suppression.added
Um endereço ou domínio entrou na lista de supressão.
suppression.removed
Um endereço ou domínio saiu da lista de supressão.
campaign.reply_received
Alguém respondeu a um e-mail de campanha, ou o servidor da pessoa respondeu por ela.
campaign.paused
Uma campanha em andamento foi pausada. O evento diz o motivo, a menos que uma pessoa ou uma chave tenha pausado.
email_check.run_completed
Um lote de verificações de endereço não tem mais nada a fazer.
email_check.run_held
Um lote de verificações de endereço continua aberto, mas nada nele anda e algumas verificações estão em espera. O evento diz o motivo e quantas esperam. Ele chega uma vez a cada espera, e o run_completed ainda chega quando as verificações terminarem.
campaign.finished
Todos os leads receberam a sequência inteira, então a campanha terminou.
campaign.waiting_for_leads
Uma campanha em andamento ficou sem ninguém para escrever.
mailbox.recovered
Um problema de uma caixa de e-mail acabou.
mailbox.address_confirmed
O endereço de uma caixa foi confirmado, então ela já pode enviar.
workspace.posture_changed
Colocamos os envios do workspace em espera, mudamos esse limite ou o retiramos. O evento diz qual é e o motivo.
mail_domain.state_changed
Um domínio de e-mail que você cadastrou mudou de estado: pending, active, failing ou removed. O evento traz o ID, o nome e o uso do domínio (inbox ou send), e o estado de onde ele saiu (from) e para onde foi (to).
mailbox.review_changed
Começou ou terminou um limite numa caixa. O mode é hold (nada sai da caixa), first_contact_brake (uma inbox de agente só responde a quem seu workspace já escreveu antes) ou null quando nada mais limita. Numa espera, o ground diz o motivo. Com spam_reports, phishing_malware, legal_order ou other, nossa equipe colocou esta caixa em espera depois de uma denúncia sobre ela. Com complaint_rate, bounce_rate, policy_bounce_rate ou policy_abuse_burst, e-mails demais do domínio de uma inbox de agente ou de um endereço de remetente foram marcados como spam, deram bounce ou foram recusados como spam, então todas as inboxes de agente e todos os endereços de remetente desse domínio estão em espera. Nos outros casos, o ground é null, e os eventos enviados antes de ele existir não o trazem. Quando receiving é true, nossa equipe também impediu o endereço de receber e-mails.

Todo evento de envio (send.sent, send.failed, send.unknown, send.cancelled e send.bounced) traz o campo viaOurServers. Ele é true quando o e-mail saiu, ou sairia, pelos nossos servidores de e-mail: o e-mail de um endereço de remetente ou a resposta de uma inbox de agente. É false para uma caixa que você conectou. Eventos gravados antes de o campo existir não o têm. Com true, o send.sent quer dizer que o nosso servidor de e-mail aceitou o e-mail. Com false, quer dizer que o servidor de e-mail da sua caixa aceitou. Nenhum dos dois quer dizer que o e-mail chegou na caixa de entrada.

send.delivered e send.deferred existem só para e-mail que sai pelos nossos servidores de e-mail, como o e-mail de um endereço de remetente ou as respostas de uma inbox de agente. Cada um dispara no máximo uma vez por envio, mesmo que o servidor do destinatário peça muitas vezes para tentarmos de novo. Os dois trazem sendId, to, messageId, at (a hora em que o nosso servidor de e-mail anotou a resposta) e receiver: google, microsoft, yahoo, icloud ou o nome do próprio servidor, ou null quando não há nenhum. O send.deferred traz ainda smtpCode, enhancedCode e errorDetail, o que o servidor do destinatário disse. Um envio pode disparar send.deferred e depois send.delivered ou send.bounced. O último é como ele terminou.

Duas entregas, um e-mail

Uma entrega repetida não é um envio repetido. Mas, se o seu código envia um e-mail quando um evento chega, a repetição pode pedir um segundo.

O envio tem as próprias três proteções: a chave da requisição HTTP, a intenção de envio e o worker que toma o e-mail para si. Elas são separadas, e nenhuma substitui a sua conferência do id. Amarre cada envio a um único e-mail: o ID e a revisão de um rascunho já fazem isso, e num e-mail de teste ou transacional você pode montar o intentKey com a generation e o seq do evento.

O guia de envio explica as três proteções