Webhooks
Receber eventos e verificar a assinatura.
Um webhook é um endereço HTTPS do seu sistema que recebe um POST a cada evento da Conta. Cadastre o endereço e os eventos desejados; a entrega costuma chegar em até um minuto.
O que chega
POST /seu/endereco HTTP/1.1
Content-Type: application/json
User-Agent: FrotaSimples-Webhooks/1
FrotaSimples-Evento-Id: evt_00000000-0000-4000-8000-000000000000
FrotaSimples-Evento-Tipo: veiculo.criado
FrotaSimples-Assinatura: t=1792540800,v1=da6a...6240
{
"id": "evt_00000000-0000-4000-8000-000000000000",
"tipo": "veiculo.criado",
"versao": 1,
"criadoEm": "2026-10-20T12:00:00.000Z",
"conta": { "id": 123 },
"dados": { ... }
}dados é a mesma representação que a API devolve para o recurso — o mesmo formato da leitura na referência.
Verifique a assinatura
O cabeçalho FrotaSimples-Assinatura traz t (instante do envio, em segundos) e v1, o HMAC-SHA256 em hexadecimal de t + "." + corpo, com o segredo whsec_… mostrado ao criar o webhook. Calcule sobre o corpo cru, antes de converter o JSON; compare em tempo constante; e recuse t com mais de 5 minutos de diferença do seu relógio, para que uma entrega capturada não possa ser reenviada depois.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCIA_S = 300 // 5 minutos
/**
* corpoCru: o texto exato recebido (antes do JSON.parse).
* cabecalho: o valor de FrotaSimples-Assinatura.
* segredo: o whsec_... mostrado ao criar o webhook.
*/
export function assinaturaValida(corpoCru, cabecalho, segredo, agora = Date.now() / 1000) {
const partes = Object.fromEntries(
String(cabecalho || '').split(',').map((p) => p.trim().split('='))
)
const t = Number(partes.t)
if (!Number.isFinite(t) || !partes.v1) return false
if (Math.abs(agora - t) > TOLERANCIA_S) return false
const esperado = createHmac('sha256', segredo).update(t + '.' + corpoCru).digest('hex')
const recebido = Buffer.from(partes.v1, 'hex')
const calculado = Buffer.from(esperado, 'hex')
return recebido.length === calculado.length && timingSafeEqual(recebido, calculado)
}
// Express: use express.raw({ type: 'application/json' }) para ter o corpo cru.
// app.post('/webhooks/frotasimples', express.raw({ type: 'application/json' }), (req, res) => {
// if (!assinaturaValida(req.body.toString('utf8'), req.get('FrotaSimples-Assinatura'), process.env.FS_WEBHOOK_SEGREDO)) {
// return res.sendStatus(400)
// }
// const evento = JSON.parse(req.body)
// res.sendStatus(200) // responda rápido; processe em segundo plano
// })import hashlib
import hmac
import time
TOLERANCIA_S = 300 # 5 minutos
def assinatura_valida(corpo_cru: bytes, cabecalho: str, segredo: str, agora: float | None = None) -> bool:
"""corpo_cru: os bytes exatos recebidos (request.get_data() no Flask)."""
partes = dict(p.strip().split("=", 1) for p in (cabecalho or "").split(",") if "=" in p)
try:
t = int(partes["t"])
recebido = partes["v1"]
except (KeyError, ValueError):
return False
if abs((agora or time.time()) - t) > TOLERANCIA_S:
return False
mensagem = f"{t}.".encode() + corpo_cru
esperado = hmac.new(segredo.encode(), mensagem, hashlib.sha256).hexdigest()
return hmac.compare_digest(esperado, recebido)<?php
const TOLERANCIA_S = 300; // 5 minutos
function assinatura_valida(string $corpoCru, string $cabecalho, string $segredo, ?int $agora = null): bool
{
$partes = [];
foreach (explode(',', $cabecalho) as $parte) {
[$chave, $valor] = array_pad(explode('=', trim($parte), 2), 2, null);
$partes[$chave] = $valor;
}
if (!isset($partes['t'], $partes['v1']) || !ctype_digit($partes['t'])) {
return false;
}
$t = (int) $partes['t'];
if (abs(($agora ?? time()) - $t) > TOLERANCIA_S) {
return false;
}
$esperado = hash_hmac('sha256', $t . '.' . $corpoCru, $segredo);
return hash_equals($esperado, $partes['v1']);
}
// $corpoCru = file_get_contents('php://input');
// $cabecalho = $_SERVER['HTTP_FROTASIMPLES_ASSINATURA'] ?? '';Vetor de teste
Rode a sua implementação contra estes valores (ignorando a janela de 5 minutos) antes do primeiro evento real:
{
"segredo": "whsec_exemplo0000000000000000000000",
"cabecalho": "t=1792540800,v1=da6afd89a62d0dc763c7e21f66df663138cc61d8ae52befdede00336dcc96240",
"corpo": "{\"id\":\"evt_00000000-0000-4000-8000-000000000000\",\"tipo\":\"veiculo.criado\",\"versao\":1,\"criadoEm\":\"2026-10-20T12:00:00.000Z\",\"conta\":{\"id\":123},\"dados\":{\"id\":\"veh_exemplo\",\"placa\":\"ABC1D23\"}}"
}Responda rápido e processe uma vez
- Responda
2xxem até 10 segundos e processe depois, em segundo plano. Redirecionamento conta como falha. - A mesma entrega pode chegar mais de uma vez. Guarde o
FrotaSimples-Evento-Idjá processado e ignore repetições. - A ordem de chegada não é garantida; use
criadoEmpara ordenar.
Retentativas
Sem 2xx, a entrega é repetida 8 vezes (9 tentativas no total), com estas esperas:
| Tentativa | Espera depois da anterior | Desde a primeira |
|---|---|---|
| 2ª | 1 min | 1 min |
| 3ª | 5 min | 6 min |
| 4ª | 15 min | 21 min |
| 5ª | 1 h | 1 h 21 min |
| 6ª | 3 h | 4 h 21 min |
| 7ª | 6 h | 10 h 21 min |
| 8ª | 12 h | 22 h 21 min |
| 9ª | 24 h | 46 h 21 min |
Um endereço que passa 3 dias falhando, sem nenhuma entrega bem-sucedida, é desativado e o Proprietário da Conta recebe um e-mail. Depois de corrigir, reative o webhook na tela de Integrações.
Catálogo de eventos
Para receber um evento, a chave e o webhook precisam do escopo de leitura do recurso.
| Evento | Quando | Escopo | Versão |
|---|---|---|---|
veiculo.criado | Veículo cadastrado na Conta. | veiculos:ler | 1 |
veiculo.atualizado | Dados de um veículo alterados. | veiculos:ler | 1 |
veiculo.excluido | Veículo removido da Conta. | veiculos:ler | 1 |
motorista.criado | Motorista cadastrado. | motoristas:ler | 1 |
motorista.atualizado | Dados de um motorista alterados. | motoristas:ler | 1 |
motorista.excluido | Motorista removido. | motoristas:ler | 1 |
documento.criado | Documento enviado. | documentos:ler | 1 |
documento.atualizado | Documento alterado. | documentos:ler | 1 |
documento.excluido | Documento removido. | documentos:ler | 1 |
documento.vencendo | Documento entrou na janela de vencimento. | documentos:ler | 1 |
multa.criada | Multa registrada. | multas:ler | 1 |
multa.atualizada | Multa alterada (situação, prazo, valor). | multas:ler | 1 |
multa.excluida | Multa removida. | multas:ler | 1 |
lancamento.criado | Lançamento financeiro criado. | financeiro:ler | 1 |
lancamento.atualizado | Lançamento financeiro alterado. | financeiro:ler | 1 |
lancamento.excluido | Lançamento financeiro removido. | financeiro:ler | 1 |
fiscal.autorizado | CT-e/MDF-e/CIOT autorizado. | fiscal:ler | 1 |
fiscal.cancelado | Documento fiscal cancelado. | fiscal:ler | 1 |
fiscal.encerrado | MDF-e encerrado. | fiscal:ler | 1 |
cliente.criado | Cliente do Atendimento criado. | clientes:ler | 1 |
cliente.atualizado | Cliente do Atendimento alterado. | clientes:ler | 1 |
cliente.excluido | Cliente do Atendimento removido. | clientes:ler | 1 |
contato.criado | Contato do Atendimento criado. | contatos:ler | 1 |
contato.atualizado | Contato do Atendimento alterado. | contatos:ler | 1 |
contato.excluido | Contato do Atendimento removido. | contatos:ler | 1 |
processo.status_alterado | Processo ANTT mudou de situação. Gerado por gatilho no banco (0167). | processos:ler | 1 |
carteira.saldo_alterado | Saldo da carteira de consultas mudou (compra, consumo, estorno). | carteira:ler | 1 |
motorista.documento_vencendo | CNH, exame toxicológico ou curso MOPP de um motorista entrou na janela de vencimento. | motoristas:ler | 1 |
fiscal.rejeitado | CT-e/MDF-e rejeitado ou denegado pela SEFAZ (o motivo vem em `motivoRejeicao`). | fiscal:ler | 1 |
cliente.arquivado | Cliente do Atendimento arquivado. | clientes:ler | 1 |
cliente.mesclado | Dois clientes viraram um: `dados.sobrevivente` fica, `dados.absorvidoId` deixou de existir. | clientes:ler | 1 |
contato.mesclado | Duas fichas de contato viraram uma: `dados.sobrevivente` fica, `dados.absorvidoId` deixou de existir. | contatos:ler | 1 |