Pular para o conteúdo

Integrar

Webhooks

Assine eventos, valide a assinatura HMAC, saiba o que garantimos na entrega — e o que não dispara webhook nenhum.

Assine eventos e receba um POST a cada ocorrência. CRUD completo por chave (GET, POST, PATCH, DELETE /webhooks), escopo webhooks.manage — nenhum dono logado precisa intervir.

Criando uma assinatura

curl
curl -X POST https://api.growthaisystem.com/api/v1/webhooks \
  -H "Authorization: Bearer gos_SEU_TOKEN_AQUI" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://seu-sistema.com/hooks","events":["contact.created"]}'

A resposta (201) traz a assinatura e o secret de assinatura HMAC. Esse é o único momento em que o segredo existe fora do nosso servidor: guarde agora, não há rota que o devolva depois — perdeu, crie outra assinatura.

A URL precisa ser https:// em produção, e destino privado ou interno é recusado com 400 validation_error — tanto no cadastro quanto na hora de entregar (o DNS é reconferido a cada tentativa e o IP aprovado é fixado na conexão). Se o seu endpoint só existe atrás de DNS interno, ele nunca vai receber nada.

Eventos disponíveis

  • contact.created / contact.updated — precisa de contacts.read.
  • opportunity.created / opportunity.stage_changed / opportunity.won / opportunity.lost — precisa de opportunities.read.
  • meeting.booked / meeting.rescheduled / meeting.canceled / meeting.outcome — precisa de calendars.read.
  • message.received / message.sent — precisa de conversations.read.

Assinar um evento exige o escopo de leitura correspondente, além de webhooks.manage. Se esse escopo for revogado depois, a assinatura desativa sozinha na próxima entrega (disabledReason: "scope_revoked") e aquela entrega vira dead.

O que chega no seu endpoint

Os headers

HeaderConteúdo
x-gos-signaturet=<unix>,v1=<hmac hex>
x-gos-evento nome do evento, ex.: contact.created
x-gos-deliveryo id da entrega — o mesmo id do corpo
user-agentGrowthOS-Webhooks/1.0
content-typeapplication/json

O corpo

POST no seu endpoint
{
  "id": "whd_01HXY...",
  "event": "contact.created",
  "createdAt": "2026-09-13T18:04:11.231Z",
  "workspaceId": "ws_01H...",
  "actor": { "type": "api", "keyId": "ak_01H..." },
  "data": {
    "id": "c_01HXY...",
    "name": "Maria Souza",
    "phone": "5511988887777",
    "email": "maria@exemplo.com",
    "tags": ["canal:lp"],
    "createdAt": "2026-09-13T18:04:11.231Z",
    "updatedAt": "2026-09-13T18:04:11.231Z"
  }
}

actor.type é api (e aí vem também keyId), user, automation, ia ou system. Use isso como anti-eco: se actor.keyId é a sua própria chave, foi você que causou o evento — não reprocesse.

O que vem em data

  • contact.* — o objeto de contato completo, o mesmo de GET /contacts/:id (sem os dois campos de toque humano).
  • opportunity.* — o objeto de negócio completo. Em opportunity.stage_changed vêm dois campos a mais: fromStageId e toStageId.
  • meeting.* — não é o objeto de GET /appointments. É um objeto próprio: id, title, startAt, endAt, location, conferenceUrl, calendarKey, assigneeUserId, status, outcome, opportunityId, createdByUserId, createdBySource e contact — com { id, name, email, phone } embutido de propósito, para você não precisar de uma chamada extra por evento.
  • message.* — a mensagem de GET /conversations/:id/messages (com conversationId, contactId, channel, actor, text, transcription e providerTimestamp) mais contact ({ id, name, phone, email }) e conversation ({ id, assignedUserId, aiPaused }). message.received é só mensagem nova do contato, ao vivo — sincronização de histórico e importação não disparam. message.sent é mensagem nossa que saiu (pessoa do time, IA, automação, sistema ou API); a que a SUA chave mandou vem com actor.keyId. Áudio sai sem esperar a transcrição: leia a mensagem de novo quando transcriptionStatus for pending.

Bloqueio de agenda (compromisso sem contato e sem negócio) não vira meeting.*. Nota e tarefa também não geram evento.

Validando a assinatura

X-Gos-Signature traz t=<timestamp unix>,v1=<hex>, onde v1 é HMAC-SHA256(secret, "<t>.<corpo cru>") — o mesmo modelo do Stripe, se você já validou webhook deles.

const crypto = require("node:crypto");

function verificarAssinatura(secret, corpoCru, header, toleranciaSeg = 300) {
  const partes = Object.fromEntries(
    String(header || "").split(",").map((p) => p.split("=").map((s) => s.trim())),
  );
  const t = Number(partes.t);
  if (!t || !partes.v1) return false;
  // t é o instante DA TENTATIVA, não do evento
  if (Math.abs(Date.now() / 1000 - t) > toleranciaSeg) return false;
  const esperada = crypto.createHmac("sha256", secret).update(t + "." + corpoCru).digest("hex");
  const a = Buffer.from(esperada, "utf8");
  const b = Buffer.from(partes.v1, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw é o que garante o corpo CRU — express.json() já teria destruído os bytes
app.post("/hooks/growthos", express.raw({ type: "application/json" }), (req, res) => {
  if (!verificarAssinatura(process.env.GOS_WEBHOOK_SECRET, req.body, req.get("x-gos-signature"))) {
    return res.status(401).end();
  }
  const evento = JSON.parse(req.body.toString("utf8"));
  if (jaProcessei(evento.id)) return res.status(200).end(); // dedupe por id == x-gos-delivery
  res.status(200).end();   // responda em menos de 10s
  processarDepois(evento); // e só então trabalhe
});

As regras de entrega

O entregador roda a cada 30 segundos, em lotes de 50: normal é chegar em alguns segundos, não instantaneamente. Seu endpoint tem 10 segundos para responder. Qualquer coisa fora de 2xx conta como falha — inclusive 3xx, porque não seguimos redirecionamento. Responda 2xx rápido e processe depois.

A ordem não é garantida

As entregas são reivindicadas por horário de próxima tentativa, e uma que falhou volta para o fim da fila com backoff. Você pode receber opportunity.won antes de um opportunity.stage_changed mais antigo. Ordene pelo createdAt do corpo, nunca pela ordem de chegada.

Você pode receber o mesmo evento duas vezes

A entrega é ao menos uma vez: uma entrega em voo há mais de 5 minutos pode ser reivindicada de novo. Deduplique pelo id do corpo (o mesmo do header x-gos-delivery), que é estável entre todas as tentativas da mesma entrega.

Reentrega e desativação automática

Falhou a entrega (timeout, seu sistema fora do ar, resposta não-2xx)? Novas tentativas em 1min, 5min, 30min, 2h e 12h — 6 tentativas no total, depois a entrega vira dead e está perdida (não existe reentrega manual: reconcilie por polling). 3 dias seguidos falhando desativam a assinatura inteira sozinha (disabledReason: "circuit_breaker"). Uma entrega bem-sucedida zera o contador da má fase.

Para reativar depois de corrigir o seu lado: PATCH /webhooks/:id {"active": true} — isso também limpa failingSince e disabledReason, e o circuit breaker recomeça do zero.

Quando o webhook NÃO dispara

Também não disparam: bloqueio de agenda, nota e tarefa. E, se você filtrar por actor.keyId, o que a sua própria chave causou.

Depurando uma entrega

GET /webhooks/:id/deliveries devolve as 50 entregas mais recentes desta assinatura, da mais nova para a mais antiga, com status (pending, delivered, failed, dead), attempts, responseStatus e lastError. Não pagina — nextCursor é sempre null — e entregas terminais são podadas aos 30 dias.

Se a assinatura foi desativada sozinha, disabledReason diz por quê: circuit_breaker (3 dias falhando) ou scope_revoked (a chave que a criou perdeu o escopo de leitura do evento). Para parar de receber sem perder o histórico, use PATCH {"active": false}; DELETE apaga a assinatura e todo o histórico de entregas dela.