simpla.fydocs
Hub API

Webhooks

Como receber e validar webhooks do Hub — eventos suportados, payload canônico, assinatura HMAC SHA-256, rate limit e idempotência.

Webhooks

O Hub recebe eventos de provedores externos (CRM, formulários, integrações) em endpoints públicos versionados sob /api/v1/webhooks/{orgId}. Esta página descreve os tipos de eventos suportados, o formato dos payloads, a validação de assinatura HMAC SHA-256, política de rate limit e idempotência.

Base URL: https://hub.simplafy.com.br/api/v1

Os endpoints de webhook são públicos (sem autenticação de usuário). A proteção é feita por validação de assinatura HMAC quando o segredo está configurado para a organização, somada a rate limit por orgId e por provedor.

Tipos de eventos

O Hub expõe um endpoint por provedor. Cada provedor traz seu próprio conjunto de eventos, normalizado internamente em uma estrutura comum.

ProvedorEndpointEventos suportados
Pipefy CRMPOST /api/v1/webhooks/{orgId}/crm/pipefycard.create, card.move, card.field_update, card.delete
FollowizePOST /api/v1/webhooks/{orgId}/followizeNovo lead (template-driven, sem tipo discreto no payload do provedor)

O parâmetro {orgId} é o identificador da organização Simplafy que recebe o evento. Webhooks endereçados a um orgId inexistente respondem 404 Not Found.

Eventos de tipo desconhecido (provedor Pipefy) são respondidos com 200 OK e o campo skipped: "unrecognized event". Eventos sem template ativo na organização (Followize) respondem 200 OK com message: "No active template". Em ambos os casos o provedor NÃO deve reenviar.

Payload canônico

Cada provedor entrega seu payload nativo no corpo application/json da requisição. O Hub interpreta o payload e o transforma internamente em um evento canônico antes de processar — o provedor não precisa adaptar nada.

Pipefy

Payloads de webhook Pipefy seguem o formato padrão da plataforma (data.action, data.card.id, data.card.pipe.id). O Hub usa o action para roteamento e o card.id como identificador externo do lead.

{
  "data": {
    "action": "card.move",
    "card": {
      "id": "12345678",
      "title": "Nova Cotação",
      "pipe": { "id": "302040506" }
    },
    "from": { "id": "p1", "name": "Inbox" },
    "to":   { "id": "p2", "name": "Em qualificação" }
  }
}

Webhooks de card.create do Pipefy não trazem valores de campos. Quando essa ação é recebida, o Hub faz uma chamada subsequente card(id) na API GraphQL do Pipefy para hidratar os dados — por isso o pipeline precisa ter credenciais do Pipefy configuradas para a organização.

Followize

O payload é o JSON nativo do Followize com contact, attendant, team, interests e tracking. O Hub achata a estrutura (contact.phone, attendant.name, interests.brand, etc.) e a usa para resolver o template configurado para a organização.

{
  "id": "lead-abc-123",
  "contact": {
    "name": "Maria Silva",
    "phone": "5511999998888",
    "email": "[email protected]"
  },
  "attendant": { "name": "João" },
  "team":      { "name": "DMA" },
  "interests": { "brand": "Toyota", "product": "Corolla" },
  "tracking":  { "source": "google", "medium": "cpc" }
}

O telefone é obrigatório e pode vir em contact.phone, contact.cellphone ou no nível raiz phone. Requisições sem telefone respondem 400 Bad Request.

Assinatura HMAC (X-Signature)

Quando o segredo de webhook da organização está configurado, o Hub valida a assinatura HMAC SHA-256 do corpo bruto da requisição. Se o segredo não estiver configurado, o webhook é aceito sem validação (modo de compatibilidade).

Headers reconhecidos

ProvedorHeader
PipefyX-Pipefy-Signature
FollowizeX-Webhook-Signature ou X-Hub-Signature-256

Ambos os formatos abaixo são aceitos para o valor do header:

sha256=3c2f8e7a91d4b5...
3c2f8e7a91d4b5...

Algoritmo

A assinatura é o HMAC SHA-256 do corpo bruto da requisição (UTF-8), codificado em hexadecimal:

signature = hex( hmac_sha256( secret, raw_body ) )

A comparação no servidor é feita com crypto.timingSafeEqual para evitar timing attacks. Buffers de tamanhos diferentes são rejeitados imediatamente como inválidos.

Gerar a assinatura

import crypto from "crypto";

function sign(rawBody: string, secret: string): string {
  return crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
}

const body = JSON.stringify(payload);
const signature = sign(body, process.env.WEBHOOK_SECRET!);

await fetch("https://hub.simplafy.com.br/api/v1/webhooks/ORG_ID/followize", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Webhook-Signature": `sha256=${signature}`,
  },
  body,
});
import hmac
import hashlib
import json
import requests

def sign(raw_body: str, secret: str) -> str:
    return hmac.new(
        secret.encode("utf-8"),
        raw_body.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

body = json.dumps(payload)
signature = sign(body, WEBHOOK_SECRET)

requests.post(
    "https://hub.simplafy.com.br/api/v1/webhooks/ORG_ID/followize",
    headers={
        "Content-Type": "application/json",
        "X-Webhook-Signature": f"sha256={signature}",
    },
    data=body,
)
BODY='{"id":"lead-abc","contact":{"phone":"5511999998888"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | sed 's/^.* //')

curl -X POST https://hub.simplafy.com.br/api/v1/webhooks/ORG_ID/followize \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Signature: sha256=$SIG" \
  -d "$BODY"

Respostas de falha

CenárioStatusBody
Header ausente (Followize, com segredo configurado)401{ "error": "Missing webhook signature" }
Assinatura inválida ou em formato não reconhecido401{ "error": "Invalid webhook signature" }
Assinatura inválida no endpoint Pipefy401{ "error": "Invalid signature" }
orgId não encontrado404{ "error": "Not found" }

O corpo da requisição precisa ser assinado exatamente como será enviado (mesma codificação, mesmos espaços, mesma ordem de chaves). Re-serializar o JSON entre a assinatura e o envio quebra a validação.

Retry e idempotência

Rate limit

Cada endpoint aplica rate limit por chave webhook:{provider}:{orgId}. Requisições acima do limite recebem:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json

{ "error": "Rate limit exceeded" }

Os provedores devem implementar retry com backoff exponencial ao receber 429. Recomenda-se intervalos crescentes de 1s, 5s, 30s, 2min, 10min.

Códigos para retry

StatusO que fazer
2xxSucesso — não reenviar.
400Payload inválido (ex.: telefone faltando). Não reenviar sem ajuste.
401Assinatura ausente ou inválida. Revisar segredo antes de reenviar.
404orgId desconhecido. Não reenviar.
429Rate limit. Reenviar com backoff.
5xxErro transitório. Reenviar com backoff.

Idempotência

O Hub deduplica eventos pelo identificador externo nativo de cada provedor:

  • Pipefy: card.id é a chave do lead na tabela CrmLead (pipelineId + externalId). Eventos card.move, card.field_update e card.delete são aplicados ao lead existente; card.create faz upsert.
  • Followize: usa payload.id quando presente, caindo para o telefone normalizado como fallback. O upsert é feito por (pipelineId, externalId).

Reentregas do mesmo evento são seguras — o estado final converge para o mesmo lead. Webhooks recebidos durante o horário comercial em organizações com time de atendimento humano (ex.: Brasal) podem responder 200 OK com skipped: "business_hours" sem efeito colateral; o provedor não deve reenviar.

Receber webhook (exemplo)

Exemplo mínimo de um receiver que valida assinatura HMAC, retorna 200 imediatamente e processa o evento em background.

import express from "express";
import crypto from "crypto";

const app = express();
const SECRET = process.env.WEBHOOK_SECRET!;

// IMPORTANTE: usar raw body, não express.json()
app.post(
  "/webhooks/hub",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");
    const header = req.header("x-webhook-signature") ?? "";
    const received = header.replace(/^sha256=/i, "").toLowerCase();

    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(rawBody, "utf8")
      .digest("hex");

    const a = Buffer.from(expected, "hex");
    const b = Buffer.from(received, "hex");

    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const event = JSON.parse(rawBody);

    // Processar em background, responder rápido
    queueMicrotask(() => handleEvent(event));

    return res.status(200).json({ received: true });
  },
);
import hmac
import hashlib
import os
from fastapi import FastAPI, Header, HTTPException, Request, BackgroundTasks

app = FastAPI()
SECRET = os.environ["WEBHOOK_SECRET"]

@app.post("/webhooks/hub")
async def receive(
    request: Request,
    background_tasks: BackgroundTasks,
    x_webhook_signature: str | None = Header(default=None),
):
    raw_body = await request.body()
    header = (x_webhook_signature or "").lower()
    received = header[len("sha256="):] if header.startswith("sha256=") else header

    expected = hmac.new(
        SECRET.encode("utf-8"), raw_body, hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, received):
        raise HTTPException(status_code=401, detail="Invalid signature")

    event = await request.json()
    background_tasks.add_task(handle_event, event)
    return {"received": True}

Sempre use o corpo bruto da requisição para validar a assinatura. Frameworks que parseiam o JSON antes (como express.json()) reescrevem a representação e invalidam a comparação.

On this page