# Webhooks de saída e assinatura

> Receba eventos do FORSI Chat por webhook: lista de eventos, cabeçalhos, tentativas, troca de segredo e como conferir a assinatura HMAC-SHA256.

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`](https://docs.forsi.com.br/api/webhooks/criar-webhook)) e escolha os eventos. A cada evento, mandamos um `POST` com JSON para o seu endereço, assinado com HMAC-SHA256.

- Responda `2xx` em 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`).

```json title="corpo de mensagem.recebida (resumido)"
{
  "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 dois `v1`.
- `User-Agent`: começa com `FORSI-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_`**.

> **Figura: Como conferir a assinatura de um webhook.** A FORSI calcula o HMAC-SHA256 de "<t>.<corpo cru>" com o segredo whsec_ inteiro e manda no cabeçalho X-Forsi-Signature (t=<timestamp>,v1=<hex>). O seu servidor lê o corpo cru, recusa se t estiver a mais de 5 minutos do relógio, refaz o HMAC com o mesmo segredo e compara em tempo constante com cada v1. Bateu: processe e responda 2xx em até 10 segundos. Não bateu: responda 401 e descarte.

1. Leia o corpo cru, byte a byte, antes de converter para JSON (reformatar muda a assinatura).
2. Pegue `t` e os `v1` do cabeçalho `X-Forsi-Signature`.
3. Recuse se `t` estiver a mais de 5 minutos do seu relógio (protege contra reenvio de um evento capturado).
4. Calcule o HMAC e compare em tempo constante com cada `v1`. Aceite se qualquer um bater.

```js title="Node.js (Express)"
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
});
```

```python title="Python (Flask)"
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 title="PHP"
<?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);
```

```bash title="curl + openssl (testar o seu receptor)"
# 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` / `503` do 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`](https://docs.forsi.com.br/api/webhooks/reenviar-entrega). A que já saiu uma vez é recusada com o código `entrega_ja_despachada`.
- Um webhook que falha por 24 horas seguidas é desligado e o dono recebe um aviso. Reative com [`PATCH /webhooks/{id}`](https://docs.forsi.com.br/api/webhooks/atualizar-webhook) e `{"ativo": true}`.
- A ordem de chegada não é garantida e o mesmo evento pode chegar mais de uma vez. Use o `id` do evento para ignorar repetidos e o `criado_em` para ordenar.
- [`POST /webhooks/{id}/testar`](https://docs.forsi.com.br/api/webhooks/testar-webhook) manda um evento de teste assinado; [`GET /webhooks/{id}/entregas`](https://docs.forsi.com.br/api/webhooks/listar-entregas) mostra as últimas entregas, com status e resposta.

## Trocar o segredo

[`POST /webhooks/{id}/rotacionar`](https://docs.forsi.com.br/api/webhooks/rotacionar-webhook) 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`](https://docs.forsi.com.br/api/webhooks/listar-entregas).

### Quanto tempo tenho para responder?

10 segundos. Responda `2xx` logo e faça o trabalho pesado depois, numa fila sua.

## Endpoints de Webhooks

- [`GET /webhooks`](https://docs.forsi.com.br/api/webhooks/listar-webhooks): Listar webhooks
- [`POST /webhooks`](https://docs.forsi.com.br/api/webhooks/criar-webhook): Criar webhook
- [`GET /webhooks/{id}`](https://docs.forsi.com.br/api/webhooks/ver-webhook): Ver um webhook
- [`PATCH /webhooks/{id}`](https://docs.forsi.com.br/api/webhooks/atualizar-webhook): Atualizar webhook
- [`DELETE /webhooks/{id}`](https://docs.forsi.com.br/api/webhooks/apagar-webhook): Apagar webhook
- [`POST /webhooks/{id}/rotacionar`](https://docs.forsi.com.br/api/webhooks/rotacionar-webhook): Trocar o segredo
- [`POST /webhooks/{id}/testar`](https://docs.forsi.com.br/api/webhooks/testar-webhook): Mandar um evento de teste
- [`GET /webhooks/{id}/entregas`](https://docs.forsi.com.br/api/webhooks/listar-entregas): Histórico de entregas
- [`POST /webhooks/entregas/{id}/reenviar`](https://docs.forsi.com.br/api/webhooks/reenviar-entrega): Reenviar uma entrega

---

Página: https://docs.forsi.com.br/webhooks · Índice: https://docs.forsi.com.br/llms.txt · Tudo num arquivo: https://docs.forsi.com.br/llms-full.txt
