Webhooks de saída e assinatura
Webhook é como a FORSI avisa o seu sistema quando algo acontece (mensagem recebida, status de um envio, negócio movido, compromisso marcado) sem você precisar perguntar. Cadastre um endereço https público (pela tela ou por POST /webhooks) e escolha os eventos. A cada evento, mandamos um POST com JSON para o seu endereço, assinado com HMAC-SHA256.
- Responda
2xxem até 10 segundos; outra resposta ou demora conta como falha. Não seguimos redirecionamentos. - O endereço precisa ser https, porta 443 e um nome de domínio público (sem IP, sem rede interna).
- Até 10 webhooks por organização. Downgrade que tira a API do plano também para os webhooks.
Eventos#
| Evento | Quando |
|---|---|
mensagem.recebida | O contato mandou uma mensagem. |
mensagem.enviada | Uma mensagem sua saiu (pela tela, por um agente ou pela API). |
mensagem.status | Mudou o status de uma mensagem enviada: entregue, lida, falhou... |
conversa.criada | Nasceu uma conversa nova. |
conversa.atribuida | A conversa mudou de responsável ou de equipe. |
conversa.encerrada | A conversa foi resolvida. |
contato.criado | Um contato novo foi criado. |
negocio.criado | Um negócio novo entrou no funil. |
negocio.movido | Um negócio mudou de etapa. |
negocio.ganho | Um negócio foi marcado como ganho. |
negocio.perdido | Um negócio foi marcado como perdido. |
agendamento.criado | Um compromisso foi marcado (pela tela, pelo agente ou pela API). |
agendamento.remarcado | Um compromisso mudou de dia, hora ou agenda. Vem com o horário anterior. |
agendamento.cancelado | Um compromisso foi cancelado. |
agendamento.confirmado | Um compromisso foi confirmado. |
membro.convidado | Alguém foi convidado para a equipe (pela tela ou pela API). |
membro.entrou | Uma pessoa aceitou o convite e entrou na equipe. |
membro.alterado | Mudou o papel ou as permissões de alguém. Vem com o valor anterior. |
membro.removido | Uma pessoa saiu da equipe. |
Todos têm o mesmo envelope: id (o mesmo em todas as tentativas), evento, criado_em, organizacao_id e dados. Em dados vem o que importa ao evento: mensagem, conversa, contato, negocio, agendamento, membro, convite... Mudanças trazem estado_anterior ou anterior (agendamento.remarcado e membro.alterado).
{
"id": "5e458e2a-2f89-4c56-8f5e-f6bfa9ef0a35",
"evento": "mensagem.recebida",
"criado_em": "2026-10-04T13:05:12.481Z",
"organizacao_id": "dddecb67-6b55-43da-94f6-18c8d6680571",
"dados": {
"mensagem": {
"id": "4e8cb5d0-2d89-4fc2-93bb-c8e5e17d4629",
"conversa_id": "495a7cae-128c-4b66-adbc-807b3959c129",
"direcao": "entrada",
"tipo": "texto",
"texto": "Oi, queria remarcar minha consulta",
"autor_tipo": "contato",
"criado_em": "2026-10-04T13:05:11.902Z"
},
"conversa": {
"id": "495a7cae-128c-4b66-adbc-807b3959c129",
"estado": "aberta",
"caixa_id": "7a9d53e9-9d1b-4122-923b-fac67958f445"
},
"contato": {
"id": "08054a7f-a752-481e-9915-cf4cf944df39",
"nome": "Maria Souza",
"telefone": "+5531988887777"
}
}
}Cabeçalhos#
X-Forsi-Event: o evento.X-Forsi-Delivery: id da entrega (muda no reenvio manual).X-Forsi-Timestamp: segundos Unix do envio.X-Forsi-Signature:t=<timestamp>,v1=<hex>. Durante a troca de segredo vêm doisv1.User-Agent: começa comFORSI-Chat-Webhooks/1.
Conferir a assinatura#
Cada webhook tem um segredo (whsec_...), mostrado uma vez quando é criado ou trocado. v1 é o HMAC-SHA256, em hexadecimal, do texto <timestamp>.<corpo cru>, usando como chave o segredo inteiro, com o prefixo `whsec_`.
- Leia o corpo cru, byte a byte, antes de converter para JSON (reformatar muda a assinatura).
- Pegue
te osv1do cabeçalhoX-Forsi-Signature. - Recuse se
testiver a mais de 5 minutos do seu relógio (protege contra reenvio de um evento capturado). - Calcule o HMAC e compare em tempo constante com cada
v1. Aceite se qualquer um bater.
curl + openssl (testar o seu receptor)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SEGREDO = process.env.FORSI_WEBHOOK_SEGREDO; // whsec_... (o texto inteiro, com o prefixo)
// leia o corpo CRU (texto), antes de qualquer JSON.parse
app.post('/webhooks/forsi', express.text({ type: '*/*' }), (req, res) => {
const cabecalho = req.get('X-Forsi-Signature') ?? '';
const t = /t=(\d+)/.exec(cabecalho)?.[1];
const recebidas = [...cabecalho.matchAll(/v1=([0-9a-f]{64})/g)].map((m) => m[1]);
if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);
const esperada = crypto.createHmac('sha256', SEGREDO).update(t + '.' + req.body).digest('hex');
const ok = recebidas.some((a) => crypto.timingSafeEqual(Buffer.from(a), Buffer.from(esperada)));
if (!ok) return res.sendStatus(401);
const evento = JSON.parse(req.body);
// evento.id é o mesmo em todas as tentativas: ignore se já processou
res.sendStatus(200); // responda em até 10 s; trabalho pesado vai para a sua fila
});import hashlib
import hmac
import json
import os
import re
import time
from flask import Flask, abort, request
app = Flask(__name__)
SEGREDO = os.environ["FORSI_WEBHOOK_SEGREDO"] # whsec_... (o texto inteiro, com o prefixo)
@app.post("/webhooks/forsi")
def receber():
cabecalho = request.headers.get("X-Forsi-Signature", "")
t = re.search(r"t=(\d+)", cabecalho)
recebidas = re.findall(r"v1=([0-9a-f]{64})", cabecalho)
if not t or abs(time.time() - int(t.group(1))) > 300:
abort(400)
corpo = request.get_data() # bytes crus, antes de qualquer json.loads
esperada = hmac.new(SEGREDO.encode(), t.group(1).encode() + b"." + corpo, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(esperada, a) for a in recebidas):
abort(401)
evento = json.loads(corpo)
# evento["id"] é o mesmo em todas as tentativas: ignore se já processou
return "", 200<?php
$segredo = getenv('FORSI_WEBHOOK_SEGREDO'); // whsec_... (o texto inteiro, com o prefixo)
$corpo = file_get_contents('php://input'); // corpo cru, antes de json_decode
$cabecalho = $_SERVER['HTTP_X_FORSI_SIGNATURE'] ?? '';
preg_match('/t=(\d+)/', $cabecalho, $t);
preg_match_all('/v1=([0-9a-f]{64})/', $cabecalho, $v);
if (!$t || abs(time() - (int) $t[1]) > 300) { http_response_code(400); exit; }
$esperada = hash_hmac('sha256', $t[1] . '.' . $corpo, $segredo);
foreach ($v[1] as $a) {
if (hash_equals($esperada, $a)) {
$evento = json_decode($corpo, true);
// $evento['id'] é o mesmo em todas as tentativas: ignore se já processou
http_response_code(200);
exit;
}
}
http_response_code(401);# Simula uma entrega assinada para testar o seu receptor (mesma conta que a FORSI faz).
SEGREDO="whsec_EXAMPLE" # o segredo do webhook, inteiro, com o prefixo
T=$(date +%s)
CORPO='{"id":"9c1d2e3f-4a5b-4c6d-8e7f-0a1b2c3d4e5f","evento":"mensagem.recebida","criado_em":"2026-10-04T13:05:12.481Z","dados":{}}'
V1=$(printf '%s.%s' "$T" "$CORPO" | openssl dgst -sha256 -hmac "$SEGREDO" | sed 's/^.*= //')
curl -X POST "https://seu-sistema.com.br/webhooks/forsi" \
-H "Content-Type: application/json" \
-H "X-Forsi-Event: mensagem.recebida" \
-H "X-Forsi-Delivery: 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e" \
-H "X-Forsi-Timestamp: $T" \
-H "X-Forsi-Signature: t=$T,v1=$V1" \
--data-raw "$CORPO"Tentativas, ordem e repetição#
- Cada entrega é aceita no máximo uma vez. Se o seu endereço responder erro, demorar demais depois de receber ou cair no meio, a entrega fica como "resultado incerto" e não é repetida, para nunca duplicar um efeito no seu sistema. Só é tentada de novo (30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h e 12 h) o que comprovadamente não foi aceito: falha antes do envio (DNS, conexão recusada, certificado, conexão que não abre) ou resposta
429/503do seu servidor. Outros erros 5xx não são repetidos, pois podem ter sido processados. - Uma entrega que nunca chegou a sair (por exemplo, cancelada quando o webhook foi desligado, ou que esgotou as tentativas sem conseguir conectar) pode ser reenviada pela tela ou por
POST /webhooks/entregas/{id}/reenviar. A que já saiu uma vez é recusada com o códigoentrega_ja_despachada. - Um webhook que falha por 24 horas seguidas é desligado e o dono recebe um aviso. Reative com
PATCH /webhooks/{id}e{"ativo": true}. - A ordem de chegada não é garantida e o mesmo evento pode chegar mais de uma vez. Use o
iddo evento para ignorar repetidos e ocriado_empara ordenar. POST /webhooks/{id}/testarmanda um evento de teste assinado;GET /webhooks/{id}/entregasmostra as últimas entregas, com status e resposta.
Trocar o segredo#
POST /webhooks/{id}/rotacionar gera um segredo novo (mostrado uma vez). O antigo continua assinando junto pelas horas que você escolher (0 a 168, padrão 24): nesse tempo vêm dois v1. Atualize o segredo no seu receptor dentro desse prazo.
Perguntas frequentes#
Por que a assinatura não bate?#
Quase sempre é o corpo: o receptor converteu para JSON e de volta para texto antes de calcular, ou o framework já tinha lido o corpo. Calcule o HMAC sobre o corpo cru, exatamente como chegou. As outras causas comuns são usar o segredo sem o prefixo whsec_ e comparar com o cabeçalho inteiro em vez de cada v1.
O mesmo evento pode chegar duas vezes?#
Pode. Guarde o id do evento e ignore os repetidos. A ordem de chegada também não é garantida: ordene pelo criado_em.
Meu servidor ficou fora do ar. Perdi os eventos?#
O que não chegou a conectar (DNS, conexão recusada, certificado) é tentado de novo em 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, 6 h e 12 h. Responder 503 enquanto o servidor se recupera também faz a FORSI tentar depois. Veja o que chegou em GET /webhooks/{id}/entregas.
Quanto tempo tenho para responder?#
10 segundos. Responda 2xx logo e faça o trabalho pesado depois, numa fila sua.
Endpoints#
Referência de Webhooks- GETListar webhooks
/webhooks - POSTCriar webhook
/webhooks - GETVer um webhook
/webhooks/{id} - PATCHAtualizar webhook
/webhooks/{id} - DELApagar webhook
/webhooks/{id} - POSTTrocar o segredo
/webhooks/{id}/rotacionar - POSTMandar um evento de teste
/webhooks/{id}/testar - GETHistórico de entregas
/webhooks/{id}/entregas - POSTReenviar uma entrega
/webhooks/entregas/{id}/reenviar