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.

01

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.

1Algo acontece no Orbit
2POST assinado no seu endereço
3Você responde 200 e processa
02

Começo rápido

  1. Crie o webhook. No painel do Orbit, abra Webhooks no menu lateral e clique em Novo webhook.
  2. 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.
  3. 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ê.
  4. 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.
  5. 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:

requisição recebida
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"}}}
O primeiro evento não chegou?
A tela de webhooks guarda cada tentativa, com o código de resposta e o erro. Um 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.
03

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.

corpo
{
  "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 e o que é
CampoO que é
idIdentificador da entrega, não do evento. Uma retentativa reusa o mesmo id: é por ele que você descarta duplicata.
eventNome do evento, igual ao do catálogo.
timestampISO 8601 em UTC, do momento em que o evento aconteceu — não do momento da tentativa.
orgSlug da organização. Quem recebe eventos de várias organizações sabe de qual veio sem consultar nada.
dataO 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 e conteúdo
CabeçalhoConteúdo
X-Orbit-EventNome do evento. Permite rotear sem abrir o corpo.
X-Orbit-DeliveryO mesmo id do corpo.
X-Orbit-TimestampSegundos desde a época, do momento da tentativa. Muda a cada retentativa.
X-Orbit-Signaturesha256= seguido do HMAC em hexadecimal.
X-Webhook-SignatureCabeç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.
Campos novos podem aparecer
O conteúdo de 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.
04

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:

cadeia assinada
X-Orbit-Timestamp  +  "."  +  corpo cru, byte a byte

O timestamp entra de propósito: sem ele, quem capturasse uma entrega poderia reenviá-la para sempre. Recuse tudo que tiver mais de cinco minutos.

O erro que quase todo mundo comete

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.

o mesmo erro, nas duas versões
// 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

node.js
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

python
@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 "", 200

PHP

php
$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));
Onde ficam os bytes crus no seu framework
  • Express — express.raw() nesta rota, montado antes de qualquer express.json() global.
  • Fastify — addContentTypeParser guardando 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.

05

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.

Conversa
message.receivedUma mensagem do cliente chega.
data de message.received
{
  "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.
data de message.sent
{
  "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.
data de conversation.assumed
{
  "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.
data de conversation.returned
{
  "conversation": {
    "thread_id": "5511987654321",
    "client_name": "Marina Alves",
    "phone": "5511987654321",
    "locked_by": null,
    "ai_active": 1
  },
  "contact": {
    "id": 4417,
    "name": "Marina Alves",
    "phone": "5511987654321"
  }
}
Contato
contact.createdUm contato é criado, pela tela, pela API ou pela primeira mensagem.
data de contact.created
{
  "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.
data de contact.updated
{
  "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.
data de contact.tagged
{
  "contact": {
    "id": 4417,
    "name": "Marina Alves",
    "phone": "5511987654321",
    "tags": ["CLIENTE PJ", "VIP"]
  },
  "tag": "VIP"
}
contact.untaggedUma etiqueta é removida do contato.
data de contact.untagged
{
  "contact": {
    "id": 4417,
    "name": "Marina Alves",
    "phone": "5511987654321",
    "tags": ["CLIENTE PJ"]
  },
  "tag": "VIP"
}
Funil
lead.stage_changedUm lead muda de etapa no funil.
data de lead.stage_changed
{
  "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"
}
Propostas e tarefas
proposta.createdUma proposta é criada.
data de proposta.created
{
  "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.
data de proposta.updated
{
  "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.
data de proposta.status_changed
{
  "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.
data de task.created
{
  "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.
data de task.updated
{
  "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"
  }
}
Campanha
campaign.finishedO disparo termina, com os totais.
data de campaign.finished
{
  "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.

06

Entrega e retentativa

Sucesso é qualquer resposta 2xx. O que acontece quando não é depende do código:

Resposta e o que o orbit faz
RespostaO que o Orbit faz
2xxEntrega concluída.
408 · 429Retenta. São pedidos de espera, não recusa.
4xxErro permanente, não retenta. O outro lado disse que a requisição está errada; repetir não conserta.
5xxRetenta.
tempo esgotado · erro de redeRetenta. O limite é de 10 segundos por tentativa.

As seis tentativas

Tentativa e espera desde a anterior e tempo total
TentativaEspera desde a anteriorTempo total
1ªimediata0
2ª30 segundos30s
3ª2 minutos2min 30s
4ª10 minutos12min 30s
5ª1 hora1h 12min
6ª6 horas7h 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.

Responda 200 primeiro, processe depois

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.

07

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.

a mesma entrega, duas vezes
# 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 OK

A 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-Delivery já 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 200 e 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.
08

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

  1. Novo workflow, adicione o nó Webhook.
  2. HTTP Method: POST. Path: algo difícil de adivinhar — o caminho é parte da sua proteção.
  3. Respond: Immediately. O n8n devolve 200 assim que recebe e roda o resto do fluxo depois. É exatamente o comportamento que o Orbit espera.
  4. 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.
  5. Ative o workflow. Sem isso, a Production URL responde 404 e todas as entregas viram erro permanente.
  6. Volte ao Orbit e clique em Testar. A execução aparece no n8n com o corpo em body e os cabeçalhos em headers, com os nomes em minúsculas.
expressões no n8n
{{ $json.body.event }}                  # message.received
{{ $json.body.data.contact.phone }}     # 5511987654321
{{ $json.headers["x-orbit-delivery"] }} # 9f1c2a54-...

Make

  1. Novo cenário, primeiro módulo: Webhooks → Custom webhook.
  2. Clique em Add, dê um nome e salve. O Make gera o endereço.
  3. 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.
  4. 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.
  5. Em Show advanced settings, ligue Get request headers. Sem isso, X-Orbit-Delivery e X-Orbit-Event não ficam disponíveis no cenário — e é o primeiro que você precisa para descartar duplicata.
  6. 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.
Um webhook por evento, nas duas ferramentas
O Make aprende a estrutura a partir da primeira entrega que recebe. Se o mesmo endereço assinar 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.
Validar a assinatura dentro do Make ou do n8n

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.

09

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-Delivery processados 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.