Referência da API
Manual de criação de webhook
Manual de criação de webhook
Este é o passo a passo completo para criar um webhook no Veltra e receber os eventos com segurança: do cadastro no portal até o receptor funcionando em produção. Ele junta, em ordem de uso, o que está espalhado na referência de webhooks e eventos e no módulo 5. Para o detalhe de cada campo, a referência é a fonte completa.
Tudo aqui vem do contrato da API (contracts/openapi), do contrato dos eventos (contracts/webhooks) e do código do Veltra. Onde o produto ainda não tem algo, a página diz isso de forma explícita.
1. O que é um webhook e quando usar#
Um webhook é um aviso automático. Quando algo acontece no Veltra (o motorista chega numa parada, uma baixa é registrada, um comprovante é emitido), o Veltra faz um POST HTTP para um endereço do seu sistema, com os dados do fato em JSON.
| Quero... | Use |
|---|---|
| Ser avisado em segundos, sem ficar perguntando | Webhook |
| Buscar um dado específico agora (um documento, o PDF, o pacote de evidências) | API de leitura (GET /v1/saida/...) com a chave de integração |
| Recuperar o que perdi (servidor fora do ar, evento esquecido) | API de leitura com alteradoDesde |
A recomendação é usar os dois: o webhook para a velocidade e a consulta incremental como rede de segurança (veja a referência). O webhook leva identificadores e dados curtos, nunca arquivos: para o PDF do comprovante você recebe o comprovanteId e baixa o arquivo pela API.
A entrega é "pelo menos uma vez": o mesmo evento pode chegar mais de uma vez, e a ordem de chegada não é garantida. O receptor precisa estar preparado para isso (seção 6).
2. Antes de começar#
Quem pode criar#
- Só o perfil ADMIN da transportadora cria e gerencia webhooks. O contrato registra: "Perfil ADMIN. O perfil EMBARCADOR não acessa esta rota."
- No código dos webhooks não há exigência de módulo contratado: qualquer transportadora com perfil ADMIN acessa a tela.
Ambiente: Teste ou Produção#
Cada webhook pertence a um ambiente e ele não muda depois de criado.
| Teste | Produção | |
|---|---|---|
Endereço http:// | aceito | recusado (só https://) |
Endereço local (localhost, 127.0.0.1) | aceito | recusado |
Rede privada (10.x, 192.168.x, 172.16.x a 172.31.x) e 169.254.x | recusado | recusado |
| Eventos que chegam | só os gerados no ambiente de Teste | só os gerados em Produção |
posicao.lote | não é gerado hoje | gerado |
As proteções contra endereços internos valem nos dois ambientes (veja a seção 10 da referência); a única exceção é o loopback (localhost), liberado em Teste.
O que você precisa ter pronto#
- Um endereço do seu sistema que aceite
POSTcom corpo JSON e responda2xxem poucos segundos. Em Produção:https://com certificado de uma autoridade pública válida, sem redirecionamento. - Um lugar seguro para guardar o segredo (cofre de segredos ou variável de ambiente protegida).
- Um e-mail de contato técnico que alguém lê (é por ele que o Veltra avisa se o webhook for desativado).
- Decidir quem recebe:
TMS(o sistema da transportadora; recebe tudo, inclusiveposicao.loteeveiculo.km_registrado) ouTERCEIRO(embarcador ou cliente, limitado às raízes de CNPJ que você informar).
3. Passo a passo no portal#
Esta página não tem prints do formulário de webhook: a pasta de imagens da central ainda não tem capturas dessa tela. Os nomes de campos e botões abaixo foram conferidos no código da tela.
Abra Integrações > Webhooks
No menu lateral, grupo Integrações, item Webhooks (/integracoes/webhooks). A lista mostra Nome, URL, Eventos, Situação e Última entrega. Se ainda não houver nenhum, a tela mostra "Nenhum webhook criado".
Clique em Criar webhook e preencha o formulário
- Nome do assinante: de 1 a 120 caracteres, por exemplo "TMS da matriz".
- Ambiente: Teste ou Produção. Escolha com cuidado: não muda depois. Comece por Teste.
- Quem recebe: "Sistema da transportadora (TMS)" ou "Terceiro (embarcador, cliente)".
- URL de destino: o endereço do seu receptor. Em Produção precisa começar com
https://; em Teste,http://também vale. - Eventos: marque os tipos que quer receber (ao menos um). Marque só o que vai usar.
- Enviar posição a cada: aparece só para
TMScomposicao.lote: 5, 10 ou 15 minutos. - CNPJ do terceiro: só para
TERCEIRO. Informe a raiz (8 caracteres) ou o CNPJ inteiro; o portal guarda a raiz. - E-mail de contato técnico: obrigatório.
Salve e copie o segredo na hora
Ao salvar, o portal abre a janela "Guarde o segredo agora". O segredo aparece uma única vez, no formato whsec_ seguido de texto em Base64 (32 bytes aleatórios). Use Copiar segredo, guarde-o inteiro (com o prefixo) no cofre e só então marque "Já guardei o segredo" e clique em Concluir. Fechar a janela sem marcar pede confirmação. Se perder o segredo, não há como recuperá-lo: gere outro com Girar segredo (seção 11).
Como guardar: em um cofre de segredos ou variável de ambiente do servidor do receptor (por exemplo VELTRA_WEBHOOK_SEGREDO). Nunca em repositório, em código-fonte, em chat ou em log.
Configure o receptor
Implemente o receptor com a verificação de assinatura da seção 7 e coloque o segredo nele. Publique o receptor antes de marcar eventos de produção.
Envie um teste
Na página do webhook, clique em Enviar teste e confira o resultado (seção 9). Só depois disso trabalhe com eventos reais.
Regras que o cadastro aplica (recusa com erro se descumpridas):
- Nome de 1 a 120 caracteres; ao menos um tipo de evento, e só os 8 do catálogo da seção 5.
posicao.loteeveiculo.km_registrado: só paraTMS. UmTERCEIROcom esses tipos é recusado.TERCEIROexige ao menos uma raiz de CNPJ (8 caracteres, letras ou números).- Cadência de posição (5, 10 ou 15) só vale para
TMScomposicao.lote. - Em Produção, a URL precisa ser
https, com nome de servidor. - O e-mail de contato precisa ser válido (até 320 caracteres).
- Depois de criado, você pode editar nome, URL, tipos, papel, escopo, cadência e e-mail. Ambiente não muda. Mudar a URL não muda o segredo.
4. Criar o assinante pela API#
A mesma criação existe na API do portal, para quem prefere automatizar. Atenção: estas rotas não usam a chave de integração do TMS (X-Veltra-Api-Key). Elas usam o JWT de um usuário ADMIN (Authorization: Bearer), o mesmo token do login do portal, com a audiência veltra-api (esquema bearerAuth do contrato). Como esse token vem do login no Keycloak, para a maioria das equipes o caminho mais simples é criar pelo portal e usar a API só para consultar entregas e reenviar.
| Ação | Rota |
|---|---|
| Listar / criar | GET e POST /v1/integracoes/webhooks |
| Ver / editar / excluir | GET, PUT e DELETE /v1/integracoes/webhooks/{id} |
| Pausar / retomar | POST /v1/integracoes/webhooks/{id}/pausa e .../retomada |
| Girar o segredo | POST /v1/integracoes/webhooks/{id}/rotacao-segredo |
| Listar entregas | GET /v1/integracoes/webhooks/{id}/entregas |
| Reenviar uma entrega | POST /v1/integracoes/webhooks/{id}/entregas/{entregaId}/reenvio |
| Enviar teste | POST /v1/integracoes/webhooks/{id}/teste |
Exemplo de criação de um assinante de Teste:
curl -X POST "https://SEU_ENDERECO_AQUI/v1/integracoes/webhooks" \
-H "Authorization: Bearer SUA_CHAVE_AQUI" \
-H "Content-Type: application/json" \
-d '{
"nome": "TMS da matriz (teste)",
"ambiente": "TESTE",
"url": "https://SEU_RECEPTOR_AQUI/webhooks/veltra",
"papel": "TMS",
"tipos": ["baixa.registrada", "ocorrencia.registrada", "comprovante.emitido"],
"contatoEmail": "integracao@SEU_DOMINIO_AQUI"
}'Campos obrigatórios: nome, url, tipos, papel, contatoEmail. ambiente é opcional e, se omitido, vale PRODUCAO. Para TERCEIRO acrescente escopoCnpjs (lista de raízes de 8 caracteres); para posicao.lote de um TMS, cadenciaPosicaoMin (5, 10 ou 15).
A resposta 201 traz o assinante e o campo segredo, uma única vez.
5. Catálogo de eventos#
São 8 tipos assináveis, todos no mesmo envelope. Além deles existe o webhook.teste, que só o botão de teste envia (seção 9).
| Evento | Quando dispara | Quem recebe |
|---|---|---|
parada.chegada | o motorista registra a chegada numa parada | TMS e TERCEIRO |
parada.saida | o motorista registra a saída de uma parada | TMS e TERCEIRO |
baixa.registrada | uma baixa de documento é registrada na parada | TMS e TERCEIRO |
ocorrencia.registrada | uma ocorrência é registrada para um documento | TMS e TERCEIRO |
comprovante.emitido | o comprovante de um documento é emitido | TMS e TERCEIRO |
evento_cte.estado_alterado | muda o estado do evento 110180 (comprovante de entrega) acompanhado pelo Veltra | TMS e TERCEIRO |
posicao.lote | lote de posições de um veículo, em janelas de 5 minutos (hoje só em Produção) | só TMS |
veiculo.km_registrado | o motorista informa o km do veículo na saída da base ou na chegada | só TMS |
O envelope (igual para todos)#
| Campo | Tipo | Descrição |
|---|---|---|
id | uuid | Identificador do evento. Estável. Não é o identificador da tentativa HTTP. |
tipo | texto | Um dos tipos acima. |
versao | inteiro | Hoje sempre 1. Campo novo entra sem subir a versão: ignore campos que você não conhece. |
ocorridoEm | data e hora (UTC) | Momento do fato. |
ambiente | TESTE ou PRODUCAO | Ambiente do evento. |
dados | objeto | Muda por tipo (abaixo). |
O corpo nunca traz o identificador da transportadora.
parada.chegada e parada.saida#
dados: paradaId (uuid) e manifestoId (uuid), ambos obrigatórios.
{
"id": "018f6a2e-7c31-7a3e-8c2a-000000000004",
"tipo": "parada.chegada",
"versao": 1,
"ocorridoEm": "2026-10-22T15:00:00Z",
"ambiente": "PRODUCAO",
"dados": {
"paradaId": "22222222-2222-4222-8222-222222222222",
"manifestoId": "11111111-1111-4111-8111-111111111111"
}
}O parada.saida tem exatamente a mesma forma de dados, com "tipo": "parada.saida".
baixa.registrada#
dados: baixaId, documentoId, paradaId (uuid, obrigatórios) e, opcionais, capturadoEm (hora informada pelo celular, não é o horário legal), recebidoEm (quando o Veltra recebeu), recebedor.nome (só o nome; o documento do recebedor nunca vai no evento), coordenada (latitude, longitude, precisaoM) e quantidadeDeFotos. Se uma baixa cobrir vários documentos, o evento traz o documentoId do primeiro. Terceiro que não é o tomador do documento não recebe recebedor nem coordenada. O contrato prevê ainda pdf e canhoto, que não são preenchidos hoje: para o PDF, espere o comprovante.emitido.
{
"id": "018f6a2e-7c31-7a3e-8c2a-000000000006",
"tipo": "baixa.registrada",
"versao": 1,
"ocorridoEm": "2026-10-22T15:10:00Z",
"ambiente": "PRODUCAO",
"dados": {
"baixaId": "44444444-4444-4444-8444-444444444444",
"documentoId": "33333333-3333-4333-8333-333333333333",
"paradaId": "22222222-2222-4222-8222-222222222222",
"capturadoEm": "2026-10-22T15:10:00Z",
"recebidoEm": "2026-10-22T15:10:07Z",
"recebedor": { "nome": "Maria da Silva" },
"coordenada": { "latitude": -23.5505, "longitude": -46.6333, "precisaoM": 12.5 },
"quantidadeDeFotos": 2
}
}ocorrencia.registrada#
dados: ocorrenciaId (uuid), documentoId (uuid) e codigo (texto de 1 a 20 caracteres: o código da ocorrência cadastrada na transportadora).
{
"id": "018f6a2e-7c31-7a3e-8c2a-000000000007",
"tipo": "ocorrencia.registrada",
"versao": 1,
"ocorridoEm": "2026-10-22T15:10:00Z",
"ambiente": "PRODUCAO",
"dados": {
"ocorrenciaId": "55555555-5555-4555-8555-555555555555",
"documentoId": "33333333-3333-4333-8333-333333333333",
"codigo": "01"
}
}comprovante.emitido#
dados: comprovanteId e documentoId (uuid). Use o comprovanteId para baixar o PDF e o pacote de evidências pela API de leitura (GET /v1/saida/comprovantes/{id}/pdf e .../pacote, só com a chave de integração do TMS). O contrato prevê também pdf e canhoto opcionais; terceiro que não é o tomador nunca os recebe.
{
"id": "018f6a2e-7c31-7a3e-8c2a-000000000008",
"tipo": "comprovante.emitido",
"versao": 1,
"ocorridoEm": "2026-10-22T15:11:00Z",
"ambiente": "PRODUCAO",
"dados": {
"comprovanteId": "66666666-6666-4666-8666-666666666666",
"documentoId": "33333333-3333-4333-8333-333333333333"
}
}evento_cte.estado_alterado#
dados: documentoId (uuid), codigoEvento (sempre 110180), estado (um de PENDENTE_DE_DADOS, AGUARDANDO_CTE_COMPLETO, AGUARDANDO_REVISAO, PENDENTE_DE_ENVIO, EM_TRANSMISSAO, REGISTRADO, REGISTRADO_COM_ALERTA, REJEITADO, CANCELADO) e, opcionais, protocolo (15 dígitos) e cStat (100 a 999), que aparecem só quando já existem.
{
"id": "018f6a2e-7c31-7a3e-8c2a-00000000000a",
"tipo": "evento_cte.estado_alterado",
"versao": 1,
"ocorridoEm": "2026-10-22T15:30:00Z",
"ambiente": "PRODUCAO",
"dados": {
"documentoId": "33333333-3333-4333-8333-333333333333",
"estado": "REGISTRADO",
"codigoEvento": 110180,
"protocolo": "135260012345678",
"cStat": 135
}
}posicao.lote#
dados: placa (7 a 8 caracteres), janelaInicio, janelaFim e pontos (até 60; cada um com lat, lon, precisaoM, registradoEm). É agregado: o Veltra nunca manda um aviso por ponto de GPS e ignora posições simuladas. Só TMS. Com cadência de 10 ou 15 minutos o envio espera o próximo múltiplo, e a janela continua de 5 minutos.
{
"id": "018f6a2e-7c31-7a3e-8c2a-00000000000c",
"tipo": "posicao.lote",
"versao": 1,
"ocorridoEm": "2026-10-22T15:00:00Z",
"ambiente": "PRODUCAO",
"dados": {
"placa": "ABC1D23",
"janelaInicio": "2026-10-22T14:55:00Z",
"janelaFim": "2026-10-22T15:00:00Z",
"pontos": [
{ "lat": -23.5505, "lon": -46.6333, "precisaoM": 12, "registradoEm": "2026-10-22T14:56:10Z" },
{ "lat": -23.5521, "lon": -46.635, "precisaoM": 9, "registradoEm": "2026-10-22T14:58:40Z" }
]
}
}veiculo.km_registrado#
dados: saidaId (identifica a viagem), momento (SAIDA ou CHEGADA), placa, manifestoIds (lista de uuid), km e, opcionais, kmSaida, kmChegada e kmRodados (kmChegada - kmSaida, só em CHEGADA, quando o km de saída é conhecido). Só TMS. O Veltra não reenvia o histórico anterior à criação deste evento.
{
"id": "018f6a2e-7c31-7a3e-8c2a-000000000013",
"tipo": "veiculo.km_registrado",
"versao": 1,
"ocorridoEm": "2026-10-22T21:30:00Z",
"ambiente": "PRODUCAO",
"dados": {
"saidaId": "55555555-5555-4555-8555-555555555555",
"momento": "CHEGADA",
"placa": "ABC1D23",
"manifestoIds": ["11111111-1111-4111-8111-111111111111"],
"km": 120450,
"kmSaida": 120000,
"kmChegada": 120450,
"kmRodados": 450
}
}Os exemplos usam identificadores de mentira. Para o detalhe campo a campo (limites e descrições do contrato), veja a seção 3 da referência.
6. Como receber: o que sua rota precisa fazer#
Cada entrega é um POST HTTP/1.1 com Content-Type: application/json e estes cabeçalhos:
| Cabeçalho | Conteúdo |
|---|---|
webhook-id | Identificador da entrega: msg_ mais um UUID. Igual em todas as retentativas e no reenvio manual. |
webhook-timestamp | Segundos Unix do momento desta tentativa. Muda a cada tentativa. |
webhook-signature | v1, mais o HMAC-SHA256 em Base64. Na rotação do segredo, duas assinaturas separadas por espaço. |
Regras de ouro do receptor:
- Responda
2xxrápido (o Veltra espera até 5 segundos para conectar e 10 para ler a resposta) e processe depois: grave o evento numa tabela ou fila, devolva200ou204, e trabalhe em segundo plano. - Verifique a assinatura antes de qualquer outra coisa (seção 7), lendo o corpo cru.
- Deduplique pelo
webhook-id. Repetição é normal (resposta perdida na rede, retentativa, reenvio manual). Insira owebhook-idnuma tabela com chave única na mesma transação do processamento; se já existir, responda2xxsem processar de novo. Guarde por pelo menos 7 dias. - Não conte com a ordem. Uma entrega retentada pode chegar depois de outra mais nova. Compare
ocorridoEmpara saber qual fato é mais recente e não assuma quebaixa.registradachega antes decomprovante.emitido. - Ignore campos desconhecidos e valide os tipos antes de usar.
- Para "tente de novo mais tarde" (banco fora do ar), responda
503, não400.
| Sua resposta | O que o Veltra faz |
|---|---|
2xx | Entregue. Fim. |
429, 5xx, sem resposta, demora acima do limite, falha de DNS ou de certificado | Falha temporária: tenta de novo (seção 8). Um Retry-After maior que o próximo intervalo prevalece. |
410 | Entrega esgotada na hora e o webhook é pausado. |
3xx | Não segue o redirecionamento; entrega esgotada na hora. Cadastre a URL final. |
Outro 4xx (400, 401, 403, 404, 422...) | Sem nova tentativa: entrega esgotada na hora, vai para a fila de falhas. |
7. Verificar a assinatura (HMAC-SHA256)#
O formato é o do Standard Webhooks (ADR 0022). A string assinada, em bytes UTF-8, é:
{webhook-id} + "." + {webhook-timestamp} + "." + {corpo exato como chegou}O Veltra assina os bytes que guardou e reenvia exatamente esses bytes em cada tentativa; ele não monta o JSON de novo. Por isso o que vale é o corpo como chegou.
Os passos, na ordem:
Leia o corpo em bytes, sem mexer
Use o corpo cru. Se o framework já fez o parse do JSON e você serializa de novo, a ordem dos campos e os espaços mudam e a assinatura deixa de conferir. Configure leitura "raw" nessa rota.
Confira o relógio
Recuse se webhook-timestamp estiver a mais de 5 minutos (300 segundos) do relógio do seu servidor, para trás ou para a frente. A tolerância de 5 minutos é a recomendação deste manual; o Veltra não a impõe. Mantenha o relógio sincronizado (NTP).
Monte a string assinada
webhook-id, ponto, webhook-timestamp, ponto, corpo.
Obtenha os bytes da chave
O segredo é whsec_ + Base64. Tire o prefixo whsec_ e decodifique o Base64: os bytes resultantes (32) são a chave do HMAC. Erro comum: usar o texto do segredo, com o prefixo, como chave.
Calcule e compare em tempo constante
Calcule o HMAC-SHA256 e compare com cada assinatura que começa com v1, no cabeçalho (separadas por espaço), decodificando a parte Base64. Use a comparação em tempo constante da linguagem; não use ==.
Se nenhuma conferir, recuse
Responda 401 e não processe. Atenção: 401 não é retentado pelo Veltra e conta como entrega esgotada (seção 8). Se o seu segredo estiver errado, cada evento legítimo vai para a fila de falhas.
Os exemplos abaixo seguem exatamente esses passos e o algoritmo de AssinaturaHmac do backend. O segredo é sempre o seu, lido do ambiente (nunca escreva o valor no código).
// Node 18 ou superior. npm install express
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SEGREDO = process.env.VELTRA_WEBHOOK_SEGREDO; // "whsec_..." guardado em cofre
const TOLERANCIA_SEGUNDOS = 300;
export function verificar(segredo, id, timestamp, corpo, cabecalho) {
// corpo: Buffer com os bytes crus
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCIA_SEGUNDOS) return false;
const chave = Buffer.from(segredo.replace(/^whsec_/, ''), 'base64');
const esperado = createHmac('sha256', chave)
.update(`${id}.${timestamp}.`)
.update(corpo)
.digest();
return cabecalho.split(' ').some((parte) => {
if (!parte.startsWith('v1,')) return false;
const recebido = Buffer.from(parte.slice(3), 'base64');
return recebido.length === esperado.length && timingSafeEqual(esperado, recebido);
});
}
const app = express();
// express.raw entrega req.body como Buffer, sem mexer nos bytes.
app.post('/webhooks/veltra', express.raw({ type: '*/*', limit: '1mb' }), (req, res) => {
const id = req.get('webhook-id');
const timestamp = req.get('webhook-timestamp');
const assinatura = req.get('webhook-signature');
if (!id || !timestamp || !assinatura || !verificar(SEGREDO, id, timestamp, req.body, assinatura)) {
return res.status(401).end();
}
res.status(200).end(); // responda logo...
const evento = JSON.parse(req.body.toString('utf8'));
setImmediate(() => processar(id, evento)); // ...e processe depois
});
function processar(webhookId, evento) {
// sua regra de negócio; use webhookId para não processar duas vezes
}
app.listen(3000);# Python 3.9 ou superior. pip install flask
import base64
import hashlib
import hmac
import os
import time
from flask import Flask, request
SEGREDO = os.environ["VELTRA_WEBHOOK_SEGREDO"] # "whsec_..." guardado em cofre
TOLERANCIA_SEGUNDOS = 300
app = Flask(__name__)
def verificar(segredo: str, id_: str, timestamp: str, corpo: bytes, cabecalho: str) -> bool:
# corpo: bytes crus da requisição, sem json.loads/json.dumps
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCIA_SEGUNDOS:
return False
chave = base64.b64decode(segredo.removeprefix("whsec_"))
esperado = hmac.new(chave, f"{id_}.{timestamp}.".encode() + corpo, hashlib.sha256).digest()
for parte in cabecalho.split(" "):
if parte.startswith("v1,"):
try:
recebido = base64.b64decode(parte[3:])
except ValueError:
continue
if hmac.compare_digest(esperado, recebido):
return True
return False
@app.post("/webhooks/veltra")
def receber():
corpo = request.get_data() # bytes crus; leia antes de qualquer request.json
id_ = request.headers.get("webhook-id", "")
timestamp = request.headers.get("webhook-timestamp", "")
assinatura = request.headers.get("webhook-signature", "")
if not verificar(SEGREDO, id_, timestamp, corpo, assinatura):
return "", 401
# grave o evento numa fila ou tabela e responda logo; processe depois
return "", 200<?php
// PHP 8 ou superior.
const TOLERANCIA_SEGUNDOS = 300;
function verificar(string $segredo, string $id, string $timestamp, string $corpo, string $cabecalho): bool
{
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCIA_SEGUNDOS) {
return false;
}
$semPrefixo = str_starts_with($segredo, 'whsec_') ? substr($segredo, 6) : $segredo;
$chave = base64_decode($semPrefixo, true);
if ($chave === false) {
return false;
}
$esperado = hash_hmac('sha256', $id . '.' . $timestamp . '.' . $corpo, $chave, true);
foreach (explode(' ', $cabecalho) as $parte) {
if (!str_starts_with($parte, 'v1,')) {
continue;
}
$recebido = base64_decode(substr($parte, 3), true);
if ($recebido !== false && hash_equals($esperado, $recebido)) { // tempo constante
return true;
}
}
return false;
}
$segredo = getenv('VELTRA_WEBHOOK_SEGREDO'); // "whsec_..." guardado em cofre
$corpo = file_get_contents('php://input'); // bytes crus, antes de qualquer json_decode
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$assinatura = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
if (!$segredo || !verificar($segredo, $id, $timestamp, $corpo, $assinatura)) {
http_response_code(401);
exit;
}
// grave o evento numa fila ou tabela e responda logo; processe depois
http_response_code(200);// Java 17 ou superior, sem bibliotecas externas (servidor HTTP do próprio JDK).
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public class ReceptorVeltra {
static final String SEGREDO = System.getenv("VELTRA_WEBHOOK_SEGREDO"); // "whsec_..."
static final long TOLERANCIA_SEGUNDOS = 300;
static boolean verificar(String segredo, String id, String timestamp, byte[] corpo, String cabecalho)
throws Exception {
long ts = Long.parseLong(timestamp);
if (Math.abs(Instant.now().getEpochSecond() - ts) > TOLERANCIA_SEGUNDOS) {
return false;
}
byte[] chave = Base64.getDecoder().decode(segredo.substring("whsec_".length()));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(chave, "HmacSHA256"));
mac.update((id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8));
byte[] esperado = mac.doFinal(corpo);
for (String parte : cabecalho.split(" ")) {
if (parte.startsWith("v1,")) {
byte[] recebido;
try {
recebido = Base64.getDecoder().decode(parte.substring(3));
} catch (IllegalArgumentException invalido) {
continue;
}
if (MessageDigest.isEqual(esperado, recebido)) { // tempo constante
return true;
}
}
}
return false;
}
public static void main(String[] args) throws Exception {
HttpServer servidor = HttpServer.create(new InetSocketAddress(3000), 0);
servidor.createContext("/webhooks/veltra", ReceptorVeltra::tratar);
servidor.start();
}
static void tratar(HttpExchange troca) throws java.io.IOException {
byte[] corpo = troca.getRequestBody().readAllBytes(); // bytes crus
String id = troca.getRequestHeaders().getFirst("webhook-id");
String timestamp = troca.getRequestHeaders().getFirst("webhook-timestamp");
String assinatura = troca.getRequestHeaders().getFirst("webhook-signature");
int status = 401;
try {
if (id != null && timestamp != null && assinatura != null
&& verificar(SEGREDO, id, timestamp, corpo, assinatura)) {
status = 200; // grave o evento e processe depois
}
} catch (Exception formatoInvalido) {
status = 401;
}
troca.sendResponseHeaders(status, -1);
troca.close();
}
}// .NET 8 ou superior (projeto "web" mínimo).
using System.Security.Cryptography;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var segredo = Environment.GetEnvironmentVariable("VELTRA_WEBHOOK_SEGREDO")!; // "whsec_..."
app.MapPost("/webhooks/veltra", async (HttpRequest req) =>
{
using var memoria = new MemoryStream();
await req.Body.CopyToAsync(memoria); // bytes crus
var corpo = memoria.ToArray();
string id = req.Headers["webhook-id"].ToString();
string timestamp = req.Headers["webhook-timestamp"].ToString();
string assinatura = req.Headers["webhook-signature"].ToString();
if (!Verificador.Verificar(segredo, id, timestamp, corpo, assinatura))
return Results.StatusCode(401);
// grave o evento numa fila ou tabela e responda logo; processe depois
return Results.Ok();
});
app.Run();
static class Verificador
{
const long ToleranciaSegundos = 300;
public static bool Verificar(string segredo, string id, string timestamp, byte[] corpo, string cabecalho)
{
if (!long.TryParse(timestamp, out var ts)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > ToleranciaSegundos) return false;
byte[] chave;
try
{
chave = Convert.FromBase64String(segredo.StartsWith("whsec_") ? segredo[6..] : segredo);
}
catch (FormatException) { return false; }
var prefixo = Encoding.UTF8.GetBytes($"{id}.{timestamp}.");
var assinado = new byte[prefixo.Length + corpo.Length];
Buffer.BlockCopy(prefixo, 0, assinado, 0, prefixo.Length);
Buffer.BlockCopy(corpo, 0, assinado, prefixo.Length, corpo.Length);
using var hmac = new HMACSHA256(chave);
var esperado = hmac.ComputeHash(assinado);
foreach (var parte in cabecalho.Split(' '))
{
if (!parte.StartsWith("v1,")) continue;
try
{
var recebido = Convert.FromBase64String(parte[3..]);
if (CryptographicOperations.FixedTimeEquals(esperado, recebido)) return true; // tempo constante
}
catch (FormatException) { }
}
return false;
}
}// Go 1.21 ou superior, só biblioteca padrão.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const toleranciaSegundos = 300
func verificar(segredo, id, timestamp string, corpo []byte, cabecalho string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
diferenca := time.Now().Unix() - ts
if diferenca < 0 {
diferenca = -diferenca
}
if diferenca > toleranciaSegundos {
return false
}
chave, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(segredo, "whsec_"))
if err != nil {
return false
}
mac := hmac.New(sha256.New, chave)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(corpo)
esperado := mac.Sum(nil)
for _, parte := range strings.Split(cabecalho, " ") {
if !strings.HasPrefix(parte, "v1,") {
continue
}
recebido, err := base64.StdEncoding.DecodeString(parte[3:])
if err != nil {
continue
}
if hmac.Equal(esperado, recebido) { // tempo constante
return true
}
}
return false
}
func main() {
segredo := os.Getenv("VELTRA_WEBHOOK_SEGREDO") // "whsec_..." guardado em cofre
http.HandleFunc("/webhooks/veltra", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
corpo, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // bytes crus, até 1 MB
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
id := r.Header.Get("webhook-id")
timestamp := r.Header.Get("webhook-timestamp")
assinatura := r.Header.Get("webhook-signature")
if segredo == "" || !verificar(segredo, id, timestamp, corpo, assinatura) {
w.WriteHeader(http.StatusUnauthorized)
return
}
// grave o evento numa fila ou tabela e responda logo; processe depois
w.WriteHeader(http.StatusOK)
})
log.Fatal(http.ListenAndServe(":3000", nil))
}Durante a rotação do segredo (seção 11) o cabeçalho traz duas assinaturas (v1,NOVA v1,ANTIGA); todos os exemplos já percorrem todas as partes v1. Os exemplos não foram executados contra o Veltra real: o algoritmo foi conferido linha a linha com AssinaturaHmac.java.
8. Retentativas, pausa, desativação e fila de falhas#
Intervalos#
A primeira tentativa sai assim que o evento é distribuído (normalmente em segundos). Depois de cada falha temporária:
| Falha número | Espera até a próxima tentativa |
|---|---|
| 1 | 1 minuto |
| 2 | 5 minutos |
| 3 | 30 minutos |
| 4 | 2 horas |
| 5 | 12 horas |
| 6 em diante | 12 horas |
Cada espera varia aleatoriamente de 80% a 120% do valor. Se a resposta trouxer Retry-After maior que o intervalo da tabela, vale o Retry-After; um menor é ignorado.
Quando desiste#
O Veltra desiste quando a próxima tentativa cairia depois de 72 horas da primeira tentativa. A entrega passa a "Tentativas esgotadas" e vai para a fila de falhas (dead letter): as entregas ficam guardadas, não são apagadas, e podem ser reenviadas. Falhas definitivas (410, 3xx, outro 4xx, endereço recusado pela proteção de rede, esquema não permitido) vão para a fila de falhas na hora, sem esperar as 72 horas.
Pausado e desativado#
| Situação | Como chega lá | O que acontece |
|---|---|---|
| Pausado | você pausou, ou o endereço respondeu 410 | Não sai nenhuma entrega nova. Os eventos do período não são apagados. Ao Retomar, voltam a ser distribuídos, sem garantia de ordem. |
| Desativado automaticamente | 20 entregas esgotadas seguidas (as 20 últimas concluídas, sem nenhuma entregue no meio; uma entrega bem-sucedida zera a contagem) | O Veltra envia e-mail ao contato técnico ("Webhook desativado por falhas seguidas"). Não volta com Retomar: crie um novo webhook e configure o novo segredo no receptor. |
| Excluído | você excluiu | Na prática vira Desativado: o webhook some da lista. O histórico de entregas já gravado permanece no banco (a tabela não aceita remoção pelo papel da aplicação). |
O contrato prevê também a situação COM_FALHAS, que o Veltra ainda não aplica.
Comportamento atual do código: a distribuição considera todo evento do mesmo tipo e ambiente que ainda não tem entrega para o assinante ativo. Na prática, um assinante recém-criado ou retomado pode receber eventos mais antigos do que o cadastro. Por isso a deduplicação e o uso de
ocorridoEm(seção 6) valem desde o primeiro dia.
Reenvio manual pelo portal#
Em Integrações > Webhooks, abra o webhook, aba Fila de falhas. Cada linha tem Reenviar, e no topo há Reenviar todas (N). Corrija antes a causa (endereço, segredo, servidor fora do ar), senão as entregas voltam para a fila.
Reenvio manual pela API#
curl -X POST \
-H "Authorization: Bearer SUA_CHAVE_AQUI" \
"https://SEU_ENDERECO_AQUI/v1/integracoes/webhooks/ID_DO_WEBHOOK/entregas/ID_DA_ENTREGA/reenvio"Rota só de ADMIN (JWT do portal, como na seção 4), responde 202; se a entrega não for deste webhook, 404. O reenvio reabre a mesma entrega: mesmo webhook-id e mesmos bytes de corpo, contagem de tentativas zerada, nova janela de 72 horas, e assina de novo com webhook-timestamp novo e o segredo atual. Só sai enquanto o webhook estiver Ativo. O ID_DA_ENTREGA é o "ID da entrega" do histórico no portal (não é o cabeçalho webhook-id).
9. Testar#
O botão Enviar teste#
Na página do webhook, Enviar teste. O Veltra envia ao seu endereço um evento de tipo webhook.teste, assinado como uma entrega real, e mostra o resultado na hora ("Teste entregue: o assinante respondeu 200 em N ms" ou "O teste falhou: motivo"). Pela API: POST /v1/integracoes/webhooks/{id}/teste, que devolve entregue, codigoHttp, duracaoMs e, se falhou, motivo.
{
"id": "018f6a2e-7c31-7a3e-8c2a-0000000000ff",
"tipo": "webhook.teste",
"versao": 1,
"ocorridoEm": "2026-10-22T15:00:00Z",
"ambiente": "TESTE",
"dados": { "mensagem": "Evento de teste enviado pelo portal do Veltra." }
}- O evento de teste sempre leva
"ambiente": "TESTE", mesmo num webhook de Produção; não é um evento de negócio, não entra no histórico de entregas e não conta para a desativação por falhas seguidas. Um webhook pausado também pode ser testado. - As regras de segurança do endereço valem iguais. Motivos que o teste pode devolver incluem
ENDERECO_RECUSADO,ESQUEMA_RECUSADO,DNS,CONEXAO,COFRE_INDISPONIVELouHTTP_<código>(a resposta do seu endereço não foi2xx). - Não existe, hoje, botão para disparar um exemplo de cada evento de negócio. O botão envia só o
webhook.teste. Para verbaixa.registradae os demais, gere os fatos no ambiente de Teste do Veltra e acompanhe o histórico de entregas.
Desenvolver na sua máquina#
O servidor do Veltra só alcança endereços públicos: o localhost do seu notebook não é visível para ele. Use um túnel que dê a você um endereço https público apontando para a sua máquina (opções gratuitas: Cloudflare Tunnel com cloudflared, ngrok, localtunnel). Rode o receptor na porta 3000, abra o túnel para essa porta e cadastre o endereço https que a ferramenta mostrar. O endereço do túnel é público: deixe aberto só enquanto testa, e verifique a assinatura mesmo assim.
Testar o receptor sem o Veltra#
Você pode assinar uma chamada e disparar contra o seu receptor local, com openssl e curl:
# 1. Crie um segredo de teste (use só localmente) e configure o valor mostrado no receptor
CHAVE_HEX=$(openssl rand -hex 32)
echo "whsec_$(printf '%s' "$CHAVE_HEX" | xxd -r -p | base64)"
# 2. Corpo, id e timestamp
echo '{"id":"018f6a2e-7c31-7a3e-8c2a-000000000006","tipo":"baixa.registrada","versao":1,"ocorridoEm":"2026-10-22T15:00:00Z","ambiente":"TESTE","dados":{"baixaId":"44444444-4444-4444-8444-444444444444","documentoId":"33333333-3333-4333-8333-333333333333","paradaId":"22222222-2222-4222-8222-222222222222"}}' > corpo.json
ID="msg_teste_0001"
TS=$(date +%s)
# 3. Assine: HMAC-SHA256 de "id.timestamp.corpo", em Base64
ASSINATURA=$( { printf '%s.%s.' "$ID" "$TS"; cat corpo.json; } \
| openssl dgst -sha256 -mac HMAC -macopt hexkey:"$CHAVE_HEX" -binary | openssl base64 -A)
# 4. Envie ao receptor local
curl -i -X POST http://localhost:3000/webhooks/veltra \
-H "Content-Type: application/json" \
-H "webhook-id: $ID" \
-H "webhook-timestamp: $TS" \
-H "webhook-signature: v1,$ASSINATURA" \
--data-binary @corpo.jsonO receptor deve responder 200. Mude um caractere de corpo.json depois de assinar, ou espere mais de 5 minutos, e deve responder 401. Rode o mesmo envio duas vezes e confira que o segundo é reconhecido como repetido.
Roteiro de aceite antes de ir para Produção#
- O botão Enviar teste voltou
2xx. - Corpo alterado é recusado com
401. webhook-timestampantigo é recusado.webhook-idrepetido não é processado duas vezes.- O receptor responde em poucos segundos mesmo com o banco lento.
- A troca de segredo foi ensaiada (seção 11).
- Foi criado um webhook novo, de Produção, com
https://(o de Teste não migra de ambiente).
10. Problemas comuns#
Checklist de diagnóstico#
Olhe primeiro o Histórico de entregas do webhook (Situação, Código HTTP, Tentativas, Duração) e siga:
- Código
401em tudo → assinatura. Confira: segredo do webhook certo; prefixowhsec_tirado e Base64 decodificado; corpo lido cru; stringid.timestamp.corpo; cabeçalhos lidos sem diferenciar maiúsculas. - Sem código HTTP e esgotada na hora → o Veltra recusou o destino:
httpem Produção, endereço de rede interna, esquema não permitido ou redirecionamento. Cadastrehttpspúblico e final. - Sem código HTTP e com várias tentativas → timeout (acima de 10 s), DNS, conexão ou certificado TLS inválido. Responda rápido; confira DNS e a cadeia do certificado (autoridade pública, nome do servidor, não vencido).
404ou400→ rota errada ou validação do seu lado. Não é retentado: corrija e use Reenviar.5xxou429→ o Veltra retenta sozinho (seção 8); corrija e espere a próxima tentativa.- Nada chega e o webhook está Ativo → confira os tipos marcados, o ambiente (Teste e Produção são separados) e se o evento já é gerado naquele ambiente.
| Sintoma | Causa provável | Correção |
|---|---|---|
401 só em alguns eventos | Parse e nova serialização do JSON antes de verificar | Leia o corpo em bytes na rota do webhook |
| Funcionava, agora recusa tudo por tempo | Relógio do seu servidor fora por mais de 5 minutos | Sincronize (NTP) |
| Falha logo depois de girar o segredo | Receptor com o segredo antigo depois das 24 horas, ou duas rotações | Use o segredo mais recente; se perdeu, gire de novo |
| Webhook "Pausado" sem você ter pausado | O endereço respondeu 410 | Corrija o endereço e clique em Retomar |
| "Desativado automaticamente" | 20 entregas esgotadas seguidas | Corrija a causa e crie um webhook novo |
Teste voltou ENDERECO_RECUSADO | Endereço aponta para rede interna | Use um endereço público ou um túnel |
Não chega posicao.lote | Papel TERCEIRO, ambiente de Teste, ou o veículo não enviou posições na janela | Só TMS recebe; em Teste o evento não é gerado |
| Quero o PDF e ele não vem | O webhook leva só o comprovanteId | Baixe pela API de leitura com a chave de integração |
Perguntas frequentes#
Perdi o segredo. Dá para ver de novo? Não. Ele aparece uma única vez. Use Girar segredo.
Posso mudar um webhook de Teste para Produção? Não. O ambiente não muda. Crie outro.
O mesmo evento chegou duas vezes. É defeito? Não. A entrega é "pelo menos uma vez". Deduplique pelo webhook-id.
Os eventos chegam fora de ordem. É esperado. Ordene por ocorridoEm.
Meu servidor ficou fora do ar por mais de 72 horas e perdi eventos. Recupere pela API de leitura com alteradoDesde (veja a referência) ou use o reenvio da fila de falhas.
Existe uma lista de IPs de origem para liberar no firewall? Não há lista publicada. A prova de autenticidade é a assinatura; se sua política de rede exige liberar IPs, combine com a verificação, nunca no lugar dela.
11. Segurança e rotação de segredo#
- Verifique sempre a assinatura e o relógio. Sem isso, qualquer pessoa que descubra o endereço envia eventos falsos.
- Guarde o segredo em cofre. Nunca em repositório, código, chat ou log. Registre apenas o
webhook-ide oiddo evento, jamais o segredo nem a assinatura completa. - Não dependa de IP de origem. Não há lista de IPs; a assinatura é a prova.
- Limite o tamanho do corpo aceito (os eventos são pequenos; 1 MB sobra) e trate o conteúdo como dado: valide tipos e ignore campos desconhecidos.
- Dados pessoais. Motoristas, destinatários e localização são dados pessoais: trate-os conforme a LGPD e não os grave em logs abertos.
- HTTPS em Produção. O Veltra só entrega em
httpsnos webhooks de Produção, não segue redirecionamentos, valida o IP de cada entrega (bloqueia rede interna, link-local, CGNAT e multicast, e loopback fora do Teste) e liga a conexão direto no IP validado, para que um endereço cadastrado não faça o Veltra chamar uma rede interna.
Girar o segredo sem perder eventos#
Gire
No portal, na página do webhook, Girar segredo e confirme (API: POST /v1/integracoes/webhooks/{id}/rotacao-segredo). O novo segredo aparece uma única vez: copie e guarde.
Janela de 24 horas
Por 24 horas, cada entrega leva duas assinaturas em webhook-signature, separadas por espaço: primeiro a do segredo novo, depois a do antigo. Os receptores da seção 7 já conferem todas.
Troque no receptor
Dentro das 24 horas, atualize a variável ou o cofre com o segredo novo e reinicie o receptor. Nenhum evento é recusado nesse intervalo.
Depois de 24 horas
Só a assinatura do segredo novo é enviada; o antigo deixa de funcionar.
Se girar duas vezes dentro de 24 horas, o "anterior" passa a ser o do penúltimo giro e o primeiro deixa de valer na hora. Se o segredo vazou, um giro deixa o vazado valendo por mais 24 horas; para cortá-lo já, gire duas vezes seguidas e atualize o receptor com o último segredo logo em seguida. Editar o webhook (nome, URL, tipos) não gira o segredo.
12. Resumo#
- Webhook é um
POSTdo Veltra para o seu endereço. Responda2xxrápido e processe depois. - Três cabeçalhos:
webhook-id,webhook-timestamp,webhook-signature. - Assinatura: HMAC-SHA256 de
id.timestamp.corpo; chave = Base64 do segredo semwhsec_; comparação em tempo constante; tolerância de 5 minutos. 429,5xxe falha de rede são retentados por até 72 horas (1 min, 5 min, 30 min, 2 h, 12 h, 12 h...). Outros4xxe3xxnão;410pausa o webhook.- Duplicata é normal (deduplique por
webhook-id); ordem não é garantida. - Segredo aparece uma vez; giro tem 24 horas com duas assinaturas.
- Rede de segurança: consulta incremental com
alteradoDesde.