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 -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 decontacts.read.opportunity.created/opportunity.stage_changed/opportunity.won/opportunity.lost— precisa deopportunities.read.meeting.booked/meeting.rescheduled/meeting.canceled/meeting.outcome— precisa decalendars.read.message.received/message.sent— precisa deconversations.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
| Header | Conteúdo |
|---|---|
x-gos-signature | t=<unix>,v1=<hmac hex> |
x-gos-event | o nome do evento, ex.: contact.created |
x-gos-delivery | o id da entrega — o mesmo id do corpo |
user-agent | GrowthOS-Webhooks/1.0 |
content-type | application/json |
O corpo
{
"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. Emopportunity.stage_changedvêm dois campos a mais:fromStageIdetoStageId.meeting.*— não é o objeto de GET/appointments. É um objeto próprio:id,title,startAt,endAt,location,conferenceUrl,calendarKey,assigneeUserId,status,outcome,opportunityId,createdByUserId,createdBySourceecontact— 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(comconversationId,contactId,channel,actor,text,transcriptioneproviderTimestamp) maiscontact({ id, name, phone, email }) econversation({ 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 comactor.keyId. Áudio sai sem esperar a transcrição: leia a mensagem de novo quandotranscriptionStatusforpending.
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.