Webhooks
Conector nativo do Orbit
Como receber, validar e processar os eventos que o Orbit envia para o seu sistema. Um endereço HTTPS, um segredo e quatro linhas de código.
O que é
O Orbit avisa o seu sistema quando algo acontece: uma mensagem chega, um contato ganha etiqueta, um lead muda de etapa, uma campanha termina.
O aviso é uma requisição HTTP: o Orbit faz um POST em um endereço seu, com o que aconteceu em JSON no corpo. Você não precisa perguntar nada, nem ficar consultando a nossa API de minuto em minuto.
Começo rápido
- Crie o webhook. No painel do Orbit, abra Webhooks no menu lateral e clique em Novo webhook.
- Cole a URL. Precisa ser HTTPS e responder ao método
POST. Se ainda não existir do seu lado, um endereço de teste do n8n ou do Make serve para começar. - Escolha os eventos. Marque só o que o seu sistema vai usar. Assinar tudo é a forma mais rápida de encher o seu log de coisa que ninguém lê.
- Guarde o segredo. Ele é gerado junto com o webhook e é com ele que a assinatura é calculada. Guarde em variável de ambiente, nunca no código.
- Clique em Testar. O Orbit envia um evento de exemplo do tipo que você escolher e mostra o corpo exato que foi enviado — é o que você vai comparar com o que chegou do outro lado.
O que o seu endereço recebe, na íntegra:
POST /webhooks/orbit HTTP/1.1
Host: seu-sistema.com.br
Content-Type: application/json
Content-Length: 412
X-Orbit-Event: message.received
X-Orbit-Delivery: 9f1c2a54-3e7b-4c0a-9c1d-6f2b8a7e5d31
X-Orbit-Timestamp: 1787895070
X-Orbit-Signature: sha256=4f1e9c0b7a2d5f83c16e4b9a0d7c2f18e35a6b94c0d1e2f3a4b5c6d7e8f90123
X-Webhook-Signature: sha256=4f1e9c0b7a2d5f83c16e4b9a0d7c2f18e35a6b94c0d1e2f3a4b5c6d7e8f90123
{"id":"9f1c2a54-3e7b-4c0a-9c1d-6f2b8a7e5d31","event":"message.received","timestamp":"2026-08-28T05:31:09Z","org":"club30","data":{"message":{"id":184213,"thread_id":"5511987654321","role":"user","content":"Bom dia, ainda tem vaga para sábado?"},"contact":{"id":4417,"name":"Marina Alves","phone":"5511987654321"}}}401 quase sempre é assinatura calculada sobre o JSON reserializado; um tempo esgotado quase sempre é processamento feito antes de responder. As duas coisas estão explicadas abaixo.Formato da requisição
Sempre POST, sempre application/json. O corpo tem a mesma moldura para todo evento; só o conteúdo de data muda.
{
"id": "9f1c2a54-3e7b-4c0a-9c1d-6f2b8a7e5d31",
"event": "message.received",
"timestamp": "2026-08-28T05:31:09Z",
"org": "club30",
"data": {
"message": {
"id": 184213,
"thread_id": "5511987654321",
"role": "user",
"content": "Bom dia, ainda tem vaga para sábado?"
},
"contact": {
"id": 4417,
"name": "Marina Alves",
"phone": "5511987654321"
}
}
}| Campo | O que é |
|---|---|
id | Identificador da entrega, não do evento. Uma retentativa reusa o mesmo id: é por ele que você descarta duplicata. |
event | Nome do evento, igual ao do catálogo. |
timestamp | ISO 8601 em UTC, do momento em que o evento aconteceu — não do momento da tentativa. |
org | Slug da organização. Quem recebe eventos de várias organizações sabe de qual veio sem consultar nada. |
data | O objeto do domínio, com os mesmos nomes de campo que a API do Orbit já usa. Em evento de relação vêm os dois lados. |
Cabeçalhos
| Cabeçalho | Conteúdo |
|---|---|
X-Orbit-Event | Nome do evento. Permite rotear sem abrir o corpo. |
X-Orbit-Delivery | O mesmo id do corpo. |
X-Orbit-Timestamp | Segundos desde a época, do momento da tentativa. Muda a cada retentativa. |
X-Orbit-Signature | sha256= seguido do HMAC em hexadecimal. |
X-Webhook-Signature | Cabeçalho antigo, mantido para não quebrar quem já o usava. Tem hoje o mesmo valor do anterior. Em integração nova, use X-Orbit-Signature. |
data ganha campos conforme o produto cresce, e isso não é considerado quebra. Leia o que você precisa e ignore o resto; não valide o objeto contra um esquema fechado.Como validar a assinatura
Qualquer um pode fazer um POST no seu endereço. A assinatura é o que prova que a requisição veio do Orbit e que ninguém mexeu nela pelo caminho.
X-Orbit-Signature é um HMAC-SHA256, calculado com o segredo do webhook, sobre esta cadeia:
X-Orbit-Timestamp + "." + corpo cru, byte a byteO timestamp entra de propósito: sem ele, quem capturasse uma entrega poderia reenviá-la para sempre. Recuse tudo que tiver mais de cinco minutos.
Reserializar o JSON antes de assinar. O seu framework converte o corpo em objeto, você chama JSON.stringify ou json.dumps nele, e assina o resultado.
Só que a reserialização não devolve os mesmos bytes: a ordem das chaves muda, o espaço depois dos dois-pontos aparece ou some, acentos viram \u00e1. Um byte diferente é um hash completamente diferente — e a sua validação falha em 100% das entregas, inclusive nas legítimas.
Assine os bytes que chegaram. Converta para objeto depois de validar.
// ERRADO — o hash nunca vai bater
const corpo = JSON.stringify(req.body);
// CERTO — os bytes que chegaram, intactos
const corpo = req.body.toString("utf8");Node.js — Express
const bruto = express.raw({ type: "application/json" });
app.post("/webhooks/orbit", bruto, (req, res) => {
const ts = req.get("X-Orbit-Timestamp") || "";
// req.body é Buffer: os bytes que chegaram, sem reserializar nada.
const base = ts + "." + req.body.toString("utf8");
const esperada = "sha256=" + crypto.createHmac("sha256", SEGREDO).update(base).digest("hex");
const recebida = req.get("X-Orbit-Signature") || "";
const confere = esperada.length === recebida.length
&& crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(recebida));
const naJanela = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
if (!confere || !naJanela) return res.sendStatus(401);
res.sendStatus(200); // responde primeiro
enfileirar(JSON.parse(req.body)); // processa depois
});Python — Flask
@app.post("/webhooks/orbit")
def orbit():
ts = request.headers.get("X-Orbit-Timestamp", "")
corpo = request.get_data() # bytes crus. NUNCA request.json aqui.
base = ts.encode() + b"." + corpo
esperada = "sha256=" + hmac.new(SEGREDO.encode(), base, hashlib.sha256).hexdigest()
confere = hmac.compare_digest(esperada, request.headers.get("X-Orbit-Signature", ""))
na_janela = ts.isdigit() and abs(time.time() - int(ts)) <= 300
if not confere or not na_janela:
return "", 401
enfileirar(json.loads(corpo)) # fila, não processamento aqui
return "", 200PHP
$corpo = file_get_contents('php://input'); // bytes crus
$ts = $_SERVER['HTTP_X_ORBIT_TIMESTAMP'] ?? '';
$esperada = 'sha256=' . hash_hmac('sha256', $ts . '.' . $corpo, $SEGREDO);
$recebida = $_SERVER['HTTP_X_ORBIT_SIGNATURE'] ?? '';
if (!hash_equals($esperada, $recebida) || abs(time() - (int) $ts) > 300) {
http_response_code(401);
exit;
}
http_response_code(200);
fastcgi_finish_request(); // responde 200 e só então processa
enfileirar(json_decode($corpo, true));- Express —
express.raw()nesta rota, montado antes de qualquerexpress.json()global. - Fastify —
addContentTypeParserguardando o buffer original. - Flask —
request.get_data(). - Django —
request.body. - FastAPI —
await request.body(). - Laravel —
$request->getContent(). - Rails —
request.raw_post.
Compare as duas assinaturas com uma função de tempo constante — timingSafeEqual, hmac.compare_digest, hash_equals. Comparar com == vaza, pelo tempo de resposta, quantos caracteres iniciais estavam certos.
Catálogo de eventos
Clique em um evento para ver o conteúdo de data. Os exemplos estão abreviados: mostram os campos que você provavelmente vai usar, não a lista inteira.
message.receivedUma mensagem do cliente chega.
{
"message": {
"id": 184213,
"thread_id": "5511987654321",
"role": "user",
"content": "Bom dia, ainda tem vaga para sábado?",
"timestamp": "2026-08-28T05:31:09Z",
"media_type": null,
"media_url": null,
"wa_message_id": "wamid.HBgNNTUxMTk4NzY1NDMyMRUCABIY"
},
"contact": {
"id": 4417,
"name": "Marina Alves",
"phone": "5511987654321"
}
}message.sentO Orbit envia uma mensagem, da IA ou de um humano.
{
"message": {
"id": 184214,
"thread_id": "5511987654321",
"role": "assistant",
"content": "Bom dia, Marina! Sábado ainda tem duas vagas.",
"timestamp": "2026-08-28T05:31:14Z",
"sent_by": null
},
"contact": {
"id": 4417,
"name": "Marina Alves",
"phone": "5511987654321"
}
}conversation.assumedUm humano assume a conversa e a IA para de responder.
{
"conversation": {
"thread_id": "5511987654321",
"client_name": "Marina Alves",
"phone": "5511987654321",
"locked_by": "fernanda@club30.com.br",
"locked_at": "2026-08-28T05:33:02Z",
"ai_active": 0
},
"contact": {
"id": 4417,
"name": "Marina Alves",
"phone": "5511987654321"
}
}conversation.returnedA conversa volta para a IA.
{
"conversation": {
"thread_id": "5511987654321",
"client_name": "Marina Alves",
"phone": "5511987654321",
"locked_by": null,
"ai_active": 1
},
"contact": {
"id": 4417,
"name": "Marina Alves",
"phone": "5511987654321"
}
}contact.createdUm contato é criado, pela tela, pela API ou pela primeira mensagem.
{
"contact": {
"id": 4417,
"name": "Marina Alves",
"phone": "5511987654321",
"email": "",
"company": "",
"tags": [],
"custom_fields": {},
"created_at": "2026-08-28T05:31:09Z"
}
}contact.updatedAlgum dado do contato muda. Vem o estado DEPOIS da mudança.
{
"contact": {
"id": 4417,
"name": "Marina Alves",
"phone": "5511987654321",
"email": "marina@exemplo.com.br",
"company": "Alves Consultoria",
"tags": ["CLIENTE PJ"],
"custom_fields": { "cnpj": "12.345.678/0001-90" },
"updated_at": "2026-08-28T09:14:22Z"
}
}contact.taggedUma etiqueta é aplicada ao contato.
{
"contact": {
"id": 4417,
"name": "Marina Alves",
"phone": "5511987654321",
"tags": ["CLIENTE PJ", "VIP"]
},
"tag": "VIP"
}contact.untaggedUma etiqueta é removida do contato.
{
"contact": {
"id": 4417,
"name": "Marina Alves",
"phone": "5511987654321",
"tags": ["CLIENTE PJ"]
},
"tag": "VIP"
}lead.stage_changedUm lead muda de etapa no funil.
{
"lead": {
"id": 812,
"contact_id": 4417,
"title": "Plano anual — Marina Alves",
"value": 4800,
"currency": "BRL",
"stage_id": 3,
"assigned_to": "fernanda@club30.com.br",
"updated_at": "2026-08-28T10:02:44Z"
},
"from_stage": "Novo",
"to_stage": "Qualificado"
}proposta.createdUma proposta é criada.
{
"proposta": {
"id": 231,
"contact_id": 4417,
"title": "Plano anual",
"items": [
{ "name": "Mensalidade", "qty": 12, "unit_price": 400, "total": 4800 }
],
"total": 4800,
"status": "rascunho",
"created_by": "fernanda@club30.com.br",
"created_at": "2026-08-28T10:20:00Z"
}
}proposta.updatedOs dados de uma proposta mudam.
{
"proposta": {
"id": 231,
"title": "Plano anual",
"total": 4560,
"status": "enviada",
"valid_until": "2026-09-15",
"public_token": "p_8f2c41d0",
"updated_at": "2026-08-28T10:41:07Z"
}
}proposta.status_changedA proposta muda de situação.
{
"proposta": {
"id": 231,
"title": "Plano anual",
"total": 4560,
"status": "aceita",
"responded_at": "2026-08-28T14:02:51Z"
},
"from_status": "enviada",
"to_status": "aceita"
}task.createdUma tarefa é criada.
{
"task": {
"id": 77,
"title": "Ligar para Marina",
"description": "Confirmar o horário de sábado.",
"due_at": "2026-08-29T13:00:00Z",
"assigned_to": "fernanda@club30.com.br",
"done": false,
"contact_id": 4417
}
}task.updatedUma tarefa muda, inclusive quando é concluída.
{
"task": {
"id": 77,
"title": "Ligar para Marina",
"done": true,
"assigned_to": "fernanda@club30.com.br",
"contact_id": 4417,
"updated_at": "2026-08-29T13:22:10Z"
}
}campaign.finishedO disparo termina, com os totais.
{
"campaign": {
"id": 19,
"name": "Reativação agosto",
"status": "done",
"stats": { "total": 1941, "sent": 1902, "failed": 39 },
"created_at": "2026-08-28T11:00:00Z",
"updated_at": "2026-08-28T11:42:10Z"
}
}Entrega e leitura por destinatário ficam de fora: uma campanha de 1941 contatos viraria 1941 entregas por etapa.
Entrega e retentativa
Sucesso é qualquer resposta 2xx. O que acontece quando não é depende do código:
| Resposta | O que o Orbit faz |
|---|---|
| 2xx | Entrega concluída. |
| 408 · 429 | Retenta. São pedidos de espera, não recusa. |
| 4xx | Erro permanente, não retenta. O outro lado disse que a requisição está errada; repetir não conserta. |
| 5xx | Retenta. |
| tempo esgotado · erro de rede | Retenta. O limite é de 10 segundos por tentativa. |
As seis tentativas
| Tentativa | Espera desde a anterior | Tempo total |
|---|---|---|
| 1ª | imediata | 0 |
| 2ª | 30 segundos | 30s |
| 3ª | 2 minutos | 2min 30s |
| 4ª | 10 minutos | 12min 30s |
| 5ª | 1 hora | 1h 12min |
| 6ª | 6 horas | 7h 12min |
Depois da sexta, a entrega é marcada como falha e para. Ela continua na tela, com o histórico de tentativas, e pode ser reenviada à mão.
A fila é uma tabela no banco, não uma thread solta: reiniciar o processo do Orbit não perde entrega em voo.
O limite de 10 segundos conta o seu trabalho inteiro. Se você chamar uma API lenta, gravar em três tabelas e só então responder, uma latência qualquer transforma uma entrega bem-sucedida em tempo esgotado — e o mesmo evento volta a chegar.
Valide a assinatura, coloque numa fila, responda 200. O processamento real acontece fora do ciclo da requisição.
Idempotência
X-Orbit-Delivery identifica a entrega, e é o mesmo valor do id no corpo. Uma retentativa reusa esse identificador: só o X-Orbit-Timestamp muda — e, por consequência, a assinatura também.
# mesma entrega, dois envios: o id não muda, o timestamp sim
tentativa 1 X-Orbit-Delivery: 9f1c2a54-3e7b-4c0a-9c1d-6f2b8a7e5d31
X-Orbit-Timestamp: 1787895070 -> tempo esgotado
tentativa 2 X-Orbit-Delivery: 9f1c2a54-3e7b-4c0a-9c1d-6f2b8a7e5d31
X-Orbit-Timestamp: 1787895100 -> 200 OKA entrega é ao menos uma vez. Mesmo sem erro do seu lado, a resposta pode se perder na rede depois de você já ter processado tudo: para o Orbit foi tempo esgotado, e a entrega volta. Se cada evento vira uma cobrança, um e-mail ou uma linha nova, processar duas vezes é um problema real.
A regra é curta:
- Guarde os
X-Orbit-Deliveryjá processados — uma coluna com índice único, ou uma chave no Redis com validade de 24 horas. As seis tentativas cabem em pouco mais de 7 horas; 24 horas dá folga de sobra. - Chegou um identificador que você já tem: responda
200e não faça mais nada. Responder erro só faria o Orbit tentar de novo. - Registre o identificador no seu log junto com o resultado. Quando alguém perguntar "esse evento chegou?", é por ele que se procura nos dois lados.
- Não guarde a assinatura como chave de duplicata: ela muda a cada tentativa, porque o timestamp faz parte dela.
Receitas: Make e n8n
As duas ferramentas recebem webhook sem você escrever código. O caminho é o mesmo nas duas: elas geram um endereço, você cola esse endereço no Orbit, e usa o botão Testar para elas aprenderem o formato.
n8n
- Novo workflow, adicione o nó Webhook.
- HTTP Method:
POST. Path: algo difícil de adivinhar — o caminho é parte da sua proteção. - Respond:
Immediately. O n8n devolve 200 assim que recebe e roda o resto do fluxo depois. É exatamente o comportamento que o Orbit espera. - Copie a Production URL e cole no webhook do Orbit. A Test URL só funciona enquanto o botão de escuta está ligado, então não serve para produção.
- Ative o workflow. Sem isso, a Production URL responde 404 e todas as entregas viram erro permanente.
- Volte ao Orbit e clique em Testar. A execução aparece no n8n com o corpo em
bodye os cabeçalhos emheaders, com os nomes em minúsculas.
{{ $json.body.event }} # message.received
{{ $json.body.data.contact.phone }} # 5511987654321
{{ $json.headers["x-orbit-delivery"] }} # 9f1c2a54-...Make
- Novo cenário, primeiro módulo: Webhooks → Custom webhook.
- Clique em Add, dê um nome e salve. O Make gera o endereço.
- Copie o endereço e cole no webhook do Orbit. O Make fica esperando, com a mensagem de que está determinando a estrutura de dados.
- Volte ao Orbit e clique em Testar. O Make aprende o formato a partir dessa entrega e passa a oferecer os campos nos módulos seguintes.
- Em Show advanced settings, ligue Get request headers. Sem isso,
X-Orbit-DeliveryeX-Orbit-Eventnão ficam disponíveis no cenário — e é o primeiro que você precisa para descartar duplicata. - O Make responde 200 sozinho assim que recebe. Não acrescente um módulo de resposta a menos que você realmente precise controlar o que é devolvido.
message.received e campaign.finished, cujos data não têm nada em comum, metade dos campos vai aparecer vazia. Crie um webhook por evento no Orbit, apontando para cenários diferentes — é mais fácil de manter e de depurar.As duas ferramentas convertem o JSON em objeto antes de entregar ao seu fluxo. Sem os bytes crus, o HMAC não tem como ser conferido — e reserializar o objeto para tentar mesmo assim é justamente o erro descrito na seção 4: o hash não vai bater nunca.
Quando o corpo cru não estiver disponível, autentique por cabeçalho: no Orbit, o webhook aceita cabeçalhos personalizados; mande um segredo combinado e confira do outro lado (no n8n, pelo Header Auth do próprio nó). Some a isso um caminho longo e imprevisível na URL.
Se o evento dispara algo com valor — dinheiro, acesso, envio para o cliente final — receba em código seu, valide a assinatura como manda a seção 4, e só então chame o cenário.
Antes de ir para produção
- O endereço é HTTPS e responde em menos de 10 segundos, sempre.
- O segredo está em variável de ambiente, não no repositório.
- A assinatura é conferida sobre os bytes crus, com comparação de tempo constante.
- Entregas com mais de cinco minutos são recusadas.
- Os
X-Orbit-Deliveryprocessados ficam guardados por 24 horas. - Você responde 200 antes de processar, e o trabalho pesado vai para uma fila.
- Falha repetida do seu lado gera alerta para alguém — não só uma linha de log.
- Você assina apenas os eventos que usa.