Pular para o conteúdo

API v1 · Guia

Webhooks

Receba os eventos do Rumo CRM e confirme a assinatura Rumo-Signature em Node, Python ou PHP.

Quando algo acontece no Rumo CRM, um POST com o evento em JSON chega ao endereço que você cadastrou em Configurações → Integrações → Técnico → Webhooks.

Como confirmar que o aviso veio do Rumo CRM

Todo webhook com chave secreta leva o cabeçalho Rumo-Signature:

 
Rumo-Signature: t=1791547200,v1=9f2c5b0e7d1a4c3b8e6f0a2d4c6b8e0f1a3c5e7d9b1f3a5c7e9d1b3f5a7c9e1d
  • t é a hora do envio, em segundos desde 1970 (UTC).
  • v1 é o HMAC-SHA256, em hexadecimal, do texto <t>.<corpo>: o valor de t, um ponto e o corpo da requisição exatamente como chegou.

Para conferir:

  1. Leia o corpo cru, antes de converter para JSON. Qualquer espaço ou ordem de campo diferente muda a assinatura.
  2. Separe o cabeçalho pelas vírgulas e cada parte no primeiro =. Guarde o t e todos os v1. Ignore partes que você não conhece: no futuro pode haver mais de um v1 (por exemplo, uma assinatura por chave) e outros esquemas.
  3. Recuse se t estiver a mais de 5 minutos (300 segundos) do seu relógio, para trás ou para frente. É isso que impede alguém de capturar um aviso e reenviar depois.
  4. Calcule o HMAC-SHA256 de <t>.<corpo> com a sua chave. A chave é o texto de 64 caracteres que apareceu ao criar o webhook: use como texto, sem decodificar o hexadecimal.
  5. Compare com cada v1 usando comparação de tempo constante. Basta um bater.
  6. Responda 2xx e processe. Se o id do evento já foi processado, responda 2xx e ignore: a mesma entrega pode chegar mais de uma vez.

Mantenha o relógio do servidor sincronizado (NTP). Um relógio adiantado ou atrasado em mais de 5 minutos recusa avisos verdadeiros.

Node.js

Node.js
const crypto = require("node:crypto");

const TOLERANCIA_SEGUNDOS = 5 * 60;

// corpo: o corpo cru (Buffer ou string). cabecalho: o valor de Rumo-Signature.
function verificarAssinaturaRumo(corpo, cabecalho, segredo, agora = Math.floor(Date.now() / 1000)) {
  let timestamp = NaN;
  const assinaturas = [];
  for (const parte of String(cabecalho || "").split(",")) {
    const separador = parte.indexOf("=");
    const chave = parte.slice(0, separador).trim();
    const valor = parte.slice(separador + 1).trim();
    if (chave === "t" && /^\d+$/.test(valor)) timestamp = Number(valor);
    if (chave === "v1" && valor) assinaturas.push(valor);
  }
  if (!Number.isInteger(timestamp) || assinaturas.length === 0) return false;
  if (Math.abs(agora - timestamp) > TOLERANCIA_SEGUNDOS) return false;

  const esperada = crypto.createHmac("sha256", segredo).update(`${timestamp}.`).update(corpo).digest();
  return assinaturas.some((hex) => {
    const recebida = Buffer.from(hex, "hex");
    return recebida.length === esperada.length && crypto.timingSafeEqual(recebida, esperada);
  });
}

Com Express, receba o corpo cru só nesta rota:

Node.js
app.post("/webhooks/rumo", express.raw({ type: "application/json" }), (req, res) => {
  if (!verificarAssinaturaRumo(req.body, req.get("Rumo-Signature"), process.env.RUMO_WEBHOOK_SECRET)) {
    return res.status(400).send("assinatura inválida");
  }
  const evento = JSON.parse(req.body);
  // Guarde evento.id e ignore repetidos.
  res.sendStatus(200);
});

Python

Python
import hashlib
import hmac
import time

TOLERANCIA_SEGUNDOS = 5 * 60


def verificar_assinatura_rumo(corpo: bytes, cabecalho: str, segredo: str, agora: int | None = None) -> bool:
    timestamp, assinaturas = None, []
    for parte in (cabecalho or "").split(","):
        chave, _, valor = parte.strip().partition("=")
        if chave == "t" and valor.isdigit():
            timestamp = int(valor)
        elif chave == "v1" and valor:
            assinaturas.append(valor)
    if timestamp is None or not assinaturas:
        return False
    agora = int(time.time()) if agora is None else agora
    if abs(agora - timestamp) > TOLERANCIA_SEGUNDOS:
        return False
    esperada = hmac.new(segredo.encode(), f"{timestamp}.".encode() + corpo, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(esperada, recebida) for recebida in assinaturas)

Com Flask, o corpo cru é request.get_data() e o cabeçalho, request.headers.get("Rumo-Signature").

PHP

PHP
function verificarAssinaturaRumo(string $corpo, string $cabecalho, string $segredo, ?int $agora = null): bool
{
    $timestamp = null;
    $assinaturas = [];
    foreach (explode(',', $cabecalho) as $parte) {
        [$chave, $valor] = array_pad(explode('=', trim($parte), 2), 2, '');
        if ($chave === 't' && ctype_digit($valor)) {
            $timestamp = (int) $valor;
        } elseif ($chave === 'v1' && $valor !== '') {
            $assinaturas[] = $valor;
        }
    }
    if ($timestamp === null || $assinaturas === []) {
        return false;
    }
    if (abs(($agora ?? time()) - $timestamp) > 300) {
        return false;
    }
    $esperada = hash_hmac('sha256', $timestamp . '.' . $corpo, $segredo);
    foreach ($assinaturas as $recebida) {
        if (hash_equals($esperada, $recebida)) {
            return true;
        }
    }
    return false;
}

$corpo = file_get_contents('php://input');
$cabecalho = $_SERVER['HTTP_RUMO_SIGNATURE'] ?? '';
if (!verificarAssinaturaRumo($corpo, $cabecalho, getenv('RUMO_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}

Cabeçalho antigo

X-Webhook-Signature: sha256=<hex> continua saindo igual: HMAC-SHA256 só do corpo, com a mesma chave. Ele não leva hora, então não protege contra reenvio. Integrações novas devem usar Rumo-Signature.

Entrega e novas tentativas

  • O Rumo CRM espera a resposta por até 10 segundos. Qualquer status fora de 2xx, erro de conexão ou demora maior conta como falha.
  • Redirecionamento (3xx) não é seguido: conta como falha e entra nas novas tentativas. Cadastre a URL final. Endereço de rede interna é recusado, inclusive quando o domínio passa a apontar para um.
  • São até 8 tentativas. Depois de cada falha, a próxima sai em cerca de 1 min, 5 min, 30 min, 2 h, 4 h, 7 h e 10 h (variação de até 10% para cima ou para baixo). A última tentativa sai cerca de 24 horas depois da primeira. Esgotadas as 8, a entrega fica como Falhou.
  • Cada tentativa é assinada na hora: t e v1 mudam, o corpo e o id não.
  • A ordem de chegada não é garantida. Use timestamp do corpo para ordenar.
  • Em Histórico de entregas, Tentar agora reenvia uma entrega que não foi confirmada, com assinatura nova. A contagem de tentativas recomeça.
  • Um endereço sem nenhuma entrega confirmada por 5 dias é desativado automaticamente e os gerentes da empresa recebem um aviso no sino. Enquanto estiver desativado, os eventos não são guardados. Corrija o endereço e ative de novo em Configurações.

Troca de chave

Gerar nova chave troca a chave na hora: a anterior para de valer no mesmo instante, sem período em que as duas valem. Os avisos que o seu sistema recusar enquanto você atualiza a chave voltam nas próximas tentativas, já assinados com a chave nova. Atualize dentro do período de novas tentativas (cerca de 24 horas) e nada se perde.

Teste

Testar webhook envia {"event": "test", "timestamp": ..., "data": {...}} com os mesmos cabeçalhos de assinatura. Esse aviso não tem id, não leva X-Webhook-Id nem X-Webhook-Attempt e não entra no histórico.

Eventos

O nome do evento vem no campo event do corpo e no cabeçalho X-Webhook-Event. Escolha quais receber ao cadastrar o endereço.

  • campaign.started
  • campaign.completed
  • campaign.paused
  • message.sent
  • message.failed
  • reply.received
  • form.submitted
  • form.qualified
  • form.disqualified
  • form.abandoned
  • booking.confirmed
  • booking.canceled
  • contact.created
  • deal.created
  • deal.stage_changed
  • deal.won
  • deal.lost
PróximoChangelog