Referência da API
Referência de webhooks e eventos de saída
Referência de webhooks e eventos de saída
Esta página é para o desenvolvedor que vai receber os avisos do Veltra no sistema da transportadora (TMS), de um embarcador ou de um destinatário. Quem vai só cadastrar o endereço na tela encontra o passo a passo no módulo 5. A lista de rotas de consulta está na referência de leitura.
Esta página é escrita à mão a partir do contrato (
contracts/webhooks/), dos ADRs 0013 e 0022 e do código da entrega. Mudanças futuras nos eventos são só aditivas: campo novo entra sem subirversao, então ignore campos que você não conhece.
1. O que é um webhook e por que usar#
Pense em uma encomenda. Você pode ir à portaria de hora em hora perguntar "chegou o meu pacote?" (isso é consultar de tempos em tempos, o "polling"). Ou pode deixar o seu telefone na portaria e pedir "me ligue quando chegar" (isso é um webhook).
No Veltra é igual:
- Webhook: quando algo acontece (uma baixa, uma ocorrência, um comprovante), o Veltra faz uma chamada HTTP
POSTpara um endereço do seu sistema, com os dados do fato. - Consulta: o seu sistema pergunta ao Veltra, pela API de leitura (
GET /v1/saida/eventos), o que mudou desde a última vez.
Por que preferir o webhook para ser avisado:
| Consultar de tempos em tempos | Webhook |
|---|---|
| Você descobre o fato só na próxima rodada (minutos de atraso) | Você é avisado em poucos segundos |
| A maioria das chamadas volta vazia e gasta o seu limite por minuto | Só há chamada quando há fato |
| Você escolhe o intervalo: curto gasta recursos, longo atrasa | Não há intervalo para escolher |
O webhook tem um custo: o seu endereço precisa estar no ar, ser público, e conferir a assinatura de cada chamada. Por isso a melhor prática é usar os dois: o webhook para a velocidade e a consulta incremental (alteradoDesde) como rede de segurança, para reconciliar qualquer coisa que tenha se perdido. Veja a seção 14.
Duas características do Veltra que valem desde já:
- O webhook leva identificadores e dados curtos, não arquivos. Para o PDF do comprovante, por exemplo, você recebe o
comprovanteIde baixa o arquivo pela API de leitura com a sua chave. - A entrega é "pelo menos uma vez": se o Veltra não tiver certeza de que você recebeu, manda de novo. Por isso o seu código precisa aceitar repetição (seção 8).
2. Cadastrar o endereço e obter o segredo#
O cadastro é feito por um administrador da transportadora, no portal, em Integrações > Webhooks (/integracoes/webhooks). Cada cadastro é um assinante: um endereço que recebe um conjunto de eventos.
Prepare o endereço no seu sistema
Tenha um endereço HTTPS público que aceite POST com corpo JSON, por exemplo https://SEU_ENDERECO_AQUI/webhooks/veltra. Ele ainda não precisa verificar a assinatura para ser cadastrado, mas precisa responder 2xx quando for chamado.
Abra Webhooks e clique em Criar webhook
Preencha o formulário:
- Nome do assinante: de 1 a 120 caracteres, por exemplo "TMS da matriz".
- Quem recebe: "Sistema da transportadora (TMS)" (papel
TMS) ou "Terceiro (embarcador, cliente)" (papelTERCEIRO). - URL de destino: o endereço do passo 1. Em Produção precisa começar com
https://. Endereços internos são recusados (veja a seção 10). - Eventos: marque os tipos que você quer. Os eventos aparecem em grupos; nos grupos com mais de um evento há um botão "Marcar todos". Marque só o que for usar.
- Enviar posição a cada: aparece só para o papel
TMScom o eventoposicao.lote. Escolha 5, 10 ou 15 minutos. - CNPJ do terceiro: só para o papel
TERCEIRO. Informe as raízes (8 caracteres) ou o CNPJ inteiro; o portal guarda só a raiz. - E-mail de contato técnico: obrigatório. É para onde o Veltra avisa se o webhook for desativado.
Copie o segredo na hora
Ao salvar, o portal mostra a janela "Guarde o segredo agora". O segredo aparece uma única vez. Use "Copiar segredo", guarde em um cofre de segredos (nunca no código nem em log) e só então marque "Já guardei o segredo" e clique em "Concluir". Se perder, gere outro com "Girar segredo" (seção 9).
Configure o segredo no seu sistema
O segredo tem o formato whsec_ seguido de um texto em Base64 (que representa 32 bytes aleatórios). Guarde-o inteiro, com o prefixo, em uma variável de ambiente ou no cofre. A seção 5 mostra como usá-lo.
Envie um teste
Na página do webhook, clique em Enviar teste (seção 11) e confira se o seu endereço respondeu 2xx.
Regras que o cadastro aplica:
- Tipos: ao menos um, e só os 8 tipos do catálogo (seção 3).
posicao.lote: só o papelTMSpode assinar. O cadastro de umTERCEIROcom esse tipo é recusado.veiculo.km_registrado: idem, só o papelTMS.TERCEIRO: precisa de ao menos uma raiz de CNPJ. O terceiro só recebe os eventos de documentos cujo emitente (o embarcador) tem a raiz do CNPJ dentro do escopo dele; evento de documento de outro embarcador, ou sem documento, nunca chega a ele. Se o terceiro não é o tomador do documento, recebe o evento sem os dados de PDF e de canhoto.- Cadência: só vale para
TMScomposicao.lote; valores aceitos 5, 10 e 15. - Ambiente: um assinante pertence a um ambiente, Teste ou Produção (
ambienteno corpo; omitido vale Produção), e esse ambiente não muda depois. Eventos de Teste só chegam a assinantes de Teste, e os de Produção só a assinantes de Produção. - Editar: nome, URL, tipos, papel, escopo, cadência e e-mail podem ser mudados depois. Mudar a URL não muda o segredo.
O mesmo cadastro existe na API do portal, para quem prefere automatizar (login de administrador, não a chave de API de integração):
| 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 corpo para criar um assinante TMS:
{
"nome": "TMS da matriz",
"url": "https://SEU_ENDERECO_AQUI/webhooks/veltra",
"tipos": ["baixa.registrada", "ocorrencia.registrada", "comprovante.emitido"],
"papel": "TMS",
"contatoEmail": "integracao@SEU_DOMINIO_AQUI"
}A resposta (201) traz o assinante e o campo segredo, uma única vez.
3. Lista completa de eventos#
São 8 tipos. Todos chegam no mesmo envelope (próxima seção). Para cada um abaixo você vê quando dispara, quem pode assinar, um exemplo completo de corpo e a tabela de campos de dados.
| Tipo | Quando dispara | Quem recebe | Gerado hoje pelo Veltra |
|---|---|---|---|
parada.chegada | o motorista registra a chegada numa parada | TMS e TERCEIRO | sim |
parada.saida | o motorista registra a saída de uma parada | TMS e TERCEIRO | sim |
baixa.registrada | uma baixa de documento é registrada na parada | TMS e TERCEIRO | sim |
ocorrencia.registrada | uma ocorrência é registrada para um documento | TMS e TERCEIRO | sim |
comprovante.emitido | o comprovante de um documento é emitido | TMS e TERCEIRO | sim |
evento_cte.estado_alterado | muda o estado do evento 110180 (comprovante de entrega) acompanhado pelo Veltra | TMS e TERCEIRO | sim |
posicao.lote | a cada 5 minutos, por veículo, um lote de posições | só TMS | sim, só em Produção |
veiculo.km_registrado | o motorista informa o km do veículo na saída da base ou na chegada | só TMS | sim |
O envelope#
Todo evento, por webhook ou pela leitura /v1/saida/eventos, usa o mesmo envelope:
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
ambiente | enum | sim | valores: TESTE, PRODUCAO |
dados | objeto | sim | |
id | texto (uuid) | sim | Identificador do evento. Estável; não é o id da tentativa HTTP. |
ocorridoEm | texto (date-time) | sim | |
tipo | texto | sim | mín. tamanho: 1 |
versao | constante | sim | Versão do payload. A primeira versão é 1. Campo novo entra sem subir este número.; valor fixo: 1 |
Pontos de atenção:
ididentifica o evento e não muda. Ele não é o identificador da entrega HTTP (esse é o cabeçalhowebhook-id, seção 4).ocorridoEmé o momento do fato, em UTC (termina emZ).- O corpo nunca traz o identificador da transportadora: o assinante já é conhecido pela entrega.
dadosmuda por tipo e é descrito a seguir.
parada.chegada#
O motorista registrou a chegada numa parada. Dispara uma vez por chegada registrada. Quem recebe: TMS e TERCEIRO.
{
"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"
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
manifestoId | texto (uuid) | sim | |
paradaId | texto (uuid) | sim |
parada.saida#
O motorista registrou a saída de uma parada. Quem recebe: TMS e TERCEIRO.
{
"id": "018f6a2e-7c31-7a3e-8c2a-000000000005",
"tipo": "parada.saida",
"versao": 1,
"ocorridoEm": "2026-10-22T15:20:00Z",
"ambiente": "PRODUCAO",
"dados": {
"paradaId": "22222222-2222-4222-8222-222222222222",
"manifestoId": "11111111-1111-4111-8111-111111111111"
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
manifestoId | texto (uuid) | sim | |
paradaId | texto (uuid) | sim |
baixa.registrada#
Baixa de um documento na parada. Se uma mesma baixa cobrir vários documentos, o Veltra grava um só evento, com o documentoId do primeiro; confira os demais pela consulta. O evento traz o documentoId e o paradaId, mais o mínimo da baixa: capturadoEm, recebidoEm, recebedor.nome, coordenada e quantidadeDeFotos. O documento do recebedor nunca vai no evento (LGPD). Terceiro que não é o tomador não recebe recebedor nem coordenada. Os campos opcionais pdf e canhoto do contrato não são preenchidos hoje. Para o PDF, espere o evento comprovante.emitido e baixe pela API. Terceiro que não é o tomador do documento nunca recebe pdf nem canhoto. Quem recebe: TMS e TERCEIRO.
{
"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
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
baixaId | texto (uuid) | sim | |
capturadoEm | texto (date-time) | não | Hora da baixa informada pelo celular (igual a ocorridoEm). Não é o horário legal |
recebidoEm | texto (date-time) | não | Quando o Veltra recebeu a baixa |
recebedor | objeto | não | Só o nome de quem recebeu. Ausente se não foi informado e para terceiro que não é o tomador |
recebedor.nome | texto | sim | Máx. 120 caracteres. O documento do recebedor nunca vai no evento |
coordenada | objeto | não | Posição do celular na baixa. Ausente sem posição e para terceiro que não é o tomador |
coordenada.latitude | número | sim | mínimo: -90; máximo: 90 |
coordenada.longitude | número | sim | mínimo: -180; máximo: 180 |
coordenada.precisaoM | número | não | mínimo: 0 |
quantidadeDeFotos | inteiro | não | Quantas fotos o motorista anexou. As fotos ficam no pacote |
canhoto | objeto | não | Opcional. Terceiro que não é o tomador não recebe esta propriedade (D-11). Não traz a foto. |
canhoto.chaveNfe | texto | sim | padrão de formato: ^[0-9]{44}$ |
documentoId | texto (uuid) | sim | |
paradaId | texto (uuid) | sim | |
pdf | objeto | não | Opcional. Terceiro que não é o tomador não recebe esta propriedade (D-11). Não traz o arquivo, só a referência. |
pdf.comprovanteId | texto (uuid) | sim |
ocorrencia.registrada#
Uma ocorrência foi registrada para um documento. O codigo é o código da ocorrência cadastrada na transportadora (até 20 caracteres). Quem recebe: TMS e TERCEIRO.
{
"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"
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
codigo | texto | sim | mín. tamanho: 1; máx. tamanho: 20 |
documentoId | texto (uuid) | sim | |
ocorrenciaId | texto (uuid) | sim |
comprovante.emitido#
O comprovante do documento foi emitido (selado). 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ó para a chave de integração do TMS). O comprovante só leva assinatura ICP-Brasil quando a transportadora ativou a validação opcional; veja o módulo 6. Quem recebe: TMS e TERCEIRO.
{
"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"
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
canhoto | objeto | não | Opcional. Terceiro que não é o tomador não recebe esta propriedade (D-11). Não traz a foto. |
canhoto.chaveNfe | texto | sim | padrão de formato: ^[0-9]{44}$ |
comprovanteId | texto (uuid) | sim | |
documentoId | texto (uuid) | sim | |
pdf | objeto | não | Opcional. Terceiro que não é o tomador não recebe esta propriedade (D-11). Não traz o arquivo, só a referência. |
pdf.comprovanteId | texto (uuid) | sim |
evento_cte.estado_alterado#
Mudou o estado do evento 110180 (Comprovante de Entrega) que o Veltra acompanha. O estado vale um de: PENDENTE_DE_DADOS, AGUARDANDO_CTE_COMPLETO, AGUARDANDO_REVISAO, PENDENTE_DE_ENVIO, EM_TRANSMISSAO, REGISTRADO, REGISTRADO_COM_ALERTA, REJEITADO ou CANCELADO. protocolo e cStat aparecem só quando já existem. Quem recebe: TMS e TERCEIRO.
{
"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
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
cStat | inteiro | não | mínimo: 100; máximo: 999 |
codigoEvento | constante | sim | valor fixo: 110180 |
documentoId | texto (uuid) | sim | |
estado | enum | sim | valores: PENDENTE_DE_DADOS, AGUARDANDO_CTE_COMPLETO, AGUARDANDO_REVISAO, PENDENTE_DE_ENVIO, EM_TRANSMISSAO, REGISTRADO, REGISTRADO_COM_ALERTA, REJEITADO, CANCELADO |
protocolo | texto | não | Protocolo devolvido pela SEFAZ, quando já existe.; padrão de formato: ^[0-9]{15}$ |
posicao.lote#
Lote de posições de um veículo. É agregado: o Veltra nunca manda um aviso por ponto de GPS. A cada 5 minutos ele fecha uma janela por placa, com no máximo 60 pontos (se houver mais, escolhe pontos espalhados pela janela), e ignora posições simuladas. Só o assinante TMS recebe. Com a cadência de 10 ou 15 minutos, o envio espera o próximo múltiplo da cadência; a janela continua sendo de 5 minutos. Hoje o evento é gerado só para Produção.
{
"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"
}
]
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
janelaFim | texto (date-time) | sim | |
janelaInicio | texto (date-time) | sim | |
placa | texto | sim | mín. tamanho: 7; máx. tamanho: 8 |
pontos | lista | sim | máx. itens: 60 |
pontos[] | objeto | não | |
pontos[].lat | número | sim | mínimo: -90; máximo: 90 |
pontos[].lon | número | sim | mínimo: -180; máximo: 180 |
pontos[].precisaoM | número | sim | mínimo: 0 |
pontos[].registradoEm | texto (date-time) | sim |
veiculo.km_registrado#
O motorista informou o km do hodômetro na saída da base (momento SAIDA) ou na chegada (momento CHEGADA). Só o assinante TMS recebe; o cadastro de um TERCEIRO com esse tipo é recusado. Na chegada, kmSaida e kmRodados vêm quando o km de saída é conhecido e a chegada não é menor que a saída. O mesmo km aparece em kmSaida, kmChegada e kmRodados de GET /v1/saida/manifestos. O Veltra não reenvia o histórico anterior à versão que criou este 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
}
}| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
saidaId | texto (uuid) | sim | Identifica a viagem (saída da base) |
momento | texto | sim | SAIDA ou CHEGADA |
placa | texto | sim | Placa do veículo |
manifestoIds | lista de uuid | sim | Manifestos da viagem |
km | inteiro | sim | Km informado neste momento |
kmSaida | inteiro | não | Km de saída da viagem |
kmChegada | inteiro | não | Km de chegada (só em CHEGADA) |
kmRodados | inteiro | não | kmChegada - kmSaida (só em CHEGADA, com saída conhecida) |
4. O que chega: cabeçalhos e corpo de cada entrega#
Cada entrega é um POST HTTP/1.1 com o corpo JSON do evento e estes cabeçalhos:
| Cabeçalho | Conteúdo |
|---|---|
Content-Type | application/json |
User-Agent | Veltra-Webhook/1 |
webhook-id | identificador da entrega, no formato msg_ seguido de um UUID. É o mesmo em todas as retentativas e no reenvio manual. |
webhook-timestamp | segundos Unix (um número inteiro) do momento desta tentativa. Muda a cada tentativa. |
webhook-signature | v1, seguido do HMAC-SHA256 em Base64. Na rotação do segredo, duas assinaturas separadas por espaço. |
Esse é o formato do Standard Webhooks, conforme o ADR 0022. Veja como a chamada chega ao seu servidor (os valores de webhook-signature e o corpo são de exemplo):
POST /webhooks/veltra HTTP/1.1
Host: SEU_ENDERECO_AQUI
Content-Type: application/json
Content-Length: 302
Connection: close
User-Agent: Veltra-Webhook/1
webhook-id: msg_018f6a2e-7c31-4a3e-8c2a-0000000000aa
webhook-timestamp: 1792681203
webhook-signature: v1,ASSINATURA_EM_BASE64
{"id":"018f6a2e-7c31-7a3e-8c2a-000000000006","tipo":"baixa.registrada","versao":1,"ocorridoEm":"2026-10-22T15:00:00Z","ambiente":"PRODUCAO","dados":{"baixaId":"44444444-4444-4444-8444-444444444444","documentoId":"33333333-3333-4333-8333-333333333333","paradaId":"22222222-2222-4222-8222-222222222222"}}Os nomes de cabeçalho são minúsculos aqui; HTTP não diferencia maiúsculas de minúsculas, e o seu servidor pode entregá-los de outro jeito (por exemplo, Webhook-Id).
A string assinada é, em bytes UTF-8:
{webhook-id} + "." + {webhook-timestamp} + "." + {corpo exato como chegou}O HMAC-SHA256 dessa string, com a chave do seu segredo, em Base64, é o que vem depois de v1,.
O Veltra assina os bytes que ele guardou ao criar a entrega e reenvia exatamente esses bytes em cada tentativa. Ele não monta o JSON de novo. Por isso a ordem dos campos e os espaços do exemplo acima podem ser diferentes dos que você vê: o que vale é o que chegou.
5. Como verificar a assinatura#
Verifique sempre, antes de processar. Sem isso, qualquer pessoa que descubra o seu endereço poderia enviar eventos falsos.
Os seis passos, na ordem:
Leia o corpo em bytes, sem mexer
Use o corpo cru da requisição. Se o seu framework já fez parse do JSON e você serializa de novo, a ordem dos campos, os espaços e a codificação podem mudar, e a assinatura deixa de conferir. Configure uma leitura "raw" nessa rota (os exemplos abaixo mostram como).
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. Isso impede que alguém reaproveite uma chamada antiga que conseguiu copiar. Mantenha o relógio do servidor sincronizado (NTP). A tolerância de 5 minutos é a recomendação deste guia; o Veltra não a impõe.
Monte a string assinada
webhook-id, um ponto, webhook-timestamp, um ponto, e o corpo.
Obtenha os bytes da chave
O segredo tem o formato whsec_ + Base64. Tire o prefixo whsec_ e decodifique o Base64: os bytes resultantes são a chave do HMAC. Um erro comum é usar o texto do segredo, com o prefixo, como se fosse a chave.
Calcule o HMAC-SHA256 e compare em tempo constante
Compare o resultado com cada assinatura que começa com v1, no cabeçalho (separadas por espaço), decodificando a parte em Base64. Use a função de comparação em tempo constante da linguagem (não use ==), porque uma comparação comum pode vazar, pelo tempo de resposta, quantos bytes da assinatura estavam certos.
Se nenhuma conferir, recuse
Responda 401 e não processe. Atenção ao efeito no Veltra: um 401 não é retentado e conta como entrega esgotada (seção 6). Se o seu segredo estiver errado, os eventos vão para a fila de falhas.
As cinco implementações abaixo seguem exatamente esses passos. Os valores de segredo nos exemplos são sempre o seu (SEU_SEGREDO_AQUI), nunca um valor copiado de outro lugar.
// 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_SEU_SEGREDO_AQUI", 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 (seção 8)
}
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_SEU_SEGREDO_AQUI", 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 (seção 8)
return "", 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_SEU_SEGREDO_AQUI"
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 (seção 8)
}
} catch (Exception formatoInvalido) {
status = 401;
}
troca.sendResponseHeaders(status, -1);
troca.close();
}
}<?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_SEU_SEGREDO_AQUI", 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 (seção 8)
http_response_code(200);// .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_SEU_SEGREDO_AQUI"
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 (seção 8)
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;
}
}6. O que o seu endereço deve responder e o que o Veltra considera falha#
Regra de ouro: responda 2xx em poucos segundos e processe depois. Receber é uma coisa; processar é outra. Grave o evento (num banco ou numa fila) e devolva 200 ou 204.
O Veltra espera até 5 segundos para conectar e até 10 segundos para ler a resposta. Passou disso, é falha. Ele lê no máximo os primeiros 4 KB do corpo da resposta e ignora o conteúdo: só o código importa.
| O que o seu endereço faz | O que o Veltra entende | O que acontece |
|---|---|---|
Responde 2xx | Entregue | Fim. A entrega fica como "Entregue". |
Responde 429 ou 5xx (500, 502, 503, 504...) | Falha temporária | Nova tentativa mais tarde (seção 7). Se vier o cabeçalho Retry-After maior que o próximo intervalo, o Veltra espera esse tempo. |
| Não responde, demora mais que o limite, a conexão cai, o DNS não resolve ou o certificado HTTPS é inválido | Falha temporária | Nova tentativa mais tarde. |
Responde 410 (Gone) | "Este endereço não existe mais" | A entrega esgota na hora, o assinante é pausado e nada mais é enviado até alguém retomar. |
Responde 3xx (redirecionamento) | Falha definitiva | Não segue o redirecionamento. A entrega esgota na hora. Cadastre o endereço final. |
Responde outro 4xx (400, 401, 403, 404, 422...) | Falha definitiva | Sem nova tentativa: a entrega esgota na hora e vai para a fila de falhas. |
Consequências práticas:
- Para "tente de novo mais tarde" (banco fora do ar, fila cheia), responda
503, não400. - Um
401por assinatura inválida é o certo para um intruso, mas, se o problema for o seu segredo errado, cada evento legítimo vira uma entrega esgotada. Veja a regra das 20 entregas esgotadas na seção 7. - Não faça redirecionamento (
httpparahttps, ouwww): cadastre a URL final. - O certificado HTTPS precisa ser de uma autoridade pública reconhecida e valer para o nome do servidor. Certificado autoassinado ou vencido é falha temporária (a entrega será tentada de novo até a janela acabar).
7. Retentativa, fila de falhas e reenvio#
Intervalos exatos#
Quando uma entrega falha de forma temporária, o Veltra tenta de novo com intervalos crescentes. A primeira tentativa sai assim que o evento é distribuído (normalmente em segundos). Depois de cada falha:
| 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 sofre uma variação aleatória de 80% a 120% (por exemplo, o intervalo de 1 minuto fica entre 48 e 72 segundos), para que muitas entregas atrasadas não voltem todas no mesmo instante.
Se a resposta trouxer Retry-After (em segundos ou em data HTTP) maior que o intervalo da tabela, vale o Retry-After, limitado a 72 horas. Um Retry-After menor que o intervalo da tabela é ignorado. Se a data da próxima tentativa, com o Retry-After, passar do fim da janela de 72 horas, a entrega esgota em vez de esperar.
Quando desiste#
O Veltra desiste quando a próxima tentativa cairia depois de 72 horas contadas desde a primeira tentativa. Com a tabela acima isso dá cerca de 10 tentativas. Nesse momento a entrega passa para "Tentativas esgotadas" (estado ESGOTADA) e vai para a fila de falhas (em inglês, dead letter): um depósito das entregas que não chegaram, que não são apagadas e podem ser reenviadas.
Uma entrega também vai direto para a fila de falhas, sem esperar as 72 horas, quando a falha é definitiva: 410, redirecionamento, outro 4xx, endereço recusado pela proteção da seção 10, ou URL em esquema não permitido.
Quando o assinante é pausado ou desativado#
410: o assinante vai para Pausado. Os eventos novos deixam de gerar entrega até um administrador retomar. Enquanto o assinante estiver Pausado, nenhuma entrega sai. Os eventos desse período não são apagados e são distribuídos quando ele voltar a Ativo, sem garantia de ordem (seção 8). Para ter certeza de que nada ficou para trás, reconcilie pela consulta (seção 14).- 20 entregas esgotadas seguidas: o assinante vai para Desativado automaticamente e o Veltra envia um e-mail ao contato técnico cadastrado ("Webhook desativado por falhas seguidas"). "Seguidas" quer dizer que as 20 últimas entregas concluídas foram todas esgotadas, sem nenhuma entregue no meio. Uma única entrega bem-sucedida zera a contagem.
Reenviar pelo portal#
Em Integrações > Webhooks, abra o webhook e use a aba Fila de falhas. Cada linha tem Reenviar, e no topo há Reenviar todas (N). Corrija antes o problema que fez as entregas falharem (endereço, segredo, servidor fora do ar), senão elas voltam para a fila.
Reenviar 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/reenvioNesse exemplo, TOKEN_DO_PORTAL representa o token do login do administrador no portal (esta rota não usa a chave de API do TMS). A rota é só de administrador e responde 202. Se a entrega não for deste webhook, responde 404. O que o reenvio faz:
- reabre a mesma entrega: o
webhook-ide o corpo (os mesmos bytes) não mudam; - zera a contagem de tentativas e recomeça a janela de 72 horas;
- na próxima tentativa, assina de novo, com um
webhook-timestampnovo e o segredo atual.
O reenvio só sai enquanto o assinante estiver Ativo. Um assinante pausado só envia depois de retomado. Um assinante desativado não envia (crie um novo, seção 13).
8. Idempotência, ordem e duplicatas#
Duplicatas acontecem e são normais#
O Veltra garante entregar pelo menos uma vez. Um caso típico: o seu servidor processou o evento, mas a resposta 200 se perdeu na rede. Para o Veltra foi uma falha, e ele manda de novo. O mesmo vale para um reenvio manual e para a consulta incremental.
Duas identificações, dois usos#
| Identificação | Onde está | Muda entre tentativas? | Serve para |
|---|---|---|---|
webhook-id | cabeçalho | não (igual em retentativas e reenvios) | deduplicar entregas deste webhook |
id do envelope | corpo | não | identificar o evento; é o mesmo id que a consulta /v1/saida/eventos devolve |
Use o webhook-id para deduplicar o que chega por webhook. Use o id do envelope quando o mesmo evento puder chegar por dois caminhos (webhook e consulta) ou para mais de um webhook seu.
Como deduplicar#
- Mantenha uma tabela com o identificador como chave única.
- Ao receber, tente inserir o
webhook-id. Se a inserção funcionar, o evento é novo: processe. - Se a inserção falhar por chave repetida, o evento já foi visto: responda
2xxsem processar de novo. - Guarde os identificadores por pelo menos 7 dias (a janela de retentativa é de 72 horas, e um reenvio manual pode vir depois).
Faça o passo 2 e o processamento na mesma transação do banco, para que uma queda no meio não deixe o evento marcado como visto sem ter sido processado.
Ordem#
A ordem não é garantida. Uma entrega que falhou e foi retentada chega depois de outra mais nova. Por isso:
- não assuma que
baixa.registradachega antes decomprovante.emitido; - compare o
ocorridoEmpara saber qual fato é mais recente (útil para oestadodeevento_cte.estado_alterado); - se um evento referenciar algo que você ainda não conhece (um
documentoIdnovo), guarde o evento e reprocesse depois, ou consulte o documento na API.
9. O segredo: giro sem perder eventos#
O Veltra guarda o segredo cifrado e assina cada tentativa com ele. Gire o segredo se ele vazou, se alguém que o conhecia saiu da equipe, ou periodicamente.
Gire o segredo
No portal, na página do webhook, clique em Girar segredo e confirme. Pela API, POST /v1/integracoes/webhooks/{id}/rotacao-segredo. O novo segredo aparece uma única vez; copie e guarde.
Entenda a janela de 24 horas
A partir do giro, e por 24 horas, cada entrega leva duas assinaturas no cabeçalho webhook-signature, separadas por espaço: primeiro a do segredo novo, depois a do antigo. O exemplo:
v1,ASSINATURA_DO_SEGREDO_NOVO v1,ASSINATURA_DO_SEGREDO_ANTIGO
O código da seção 5 já percorre todas as assinaturas v1 do cabeçalho: quem ainda tem o segredo antigo continua conferindo, e quem já trocou confere pela primeira.
Troque o segredo no seu sistema
Dentro das 24 horas, atualize a variável ou o cofre com o segredo novo e reinicie o receptor. Nenhum evento é perdido nem recusado nesse intervalo.
Depois das 24 horas
Só a assinatura do segredo novo é enviada. O segredo antigo deixa de funcionar.
Detalhes importantes:
- Se você girar duas vezes dentro de 24 horas, o segredo que fica como "anterior" é o do penúltimo giro, e o primeiro segredo deixa de valer na hora. Evite girar de novo antes de terminar a troca.
- Se o segredo vazou, não espere. Um giro deixa o segredo vazado valendo por mais 24 horas; para cortá-lo já, gire duas vezes seguidas (o vazado deixa de valer) e atualize logo o receptor com o último segredo, porque o penúltimo vale só por 24 horas.
- A edição do webhook (nome, URL, tipos) não gira o segredo.
10. Segurança#
- HTTPS em Produção. O Veltra só entrega em
httpspara webhooks de Produção. Ohttpé aceito só em webhooks de Teste. - Verifique sempre a assinatura e o relógio (seção 5). É a prova de que a chamada veio do Veltra e não foi adulterada nem repetida.
- Não confie só no endereço IP de origem. Não existe uma lista de IPs para você liberar; a assinatura é a prova. Se a sua política de rede exige liberar IPs, combine isso com a verificação, nunca no lugar dela.
- Guarde o segredo em cofre. Nunca em repositório, em código ou em log. Registre em log o
webhook-ide oiddo evento, jamais o segredo nem a assinatura completa. - Limite o tamanho do corpo que o seu endpoint aceita (os eventos são pequenos; 1 MB sobra).
- Trate o conteúdo como dado. Valide o JSON e os tipos antes de usar. Ignore campos que você não conhece (mudanças futuras são aditivas).
- Dados pessoais. Motoristas, destinatários e localização são dados pessoais: trate-os conforme a LGPD nos seus sistemas e não os grave em logs abertos.
Proteção do lado do Veltra contra SSRF. SSRF é quando alguém cadastra como "endereço do webhook" um endereço interno (de uma rede privada) para fazer o servidor do Veltra chamar um lugar onde não deveria. Para impedir isso, o Veltra:
- a cada entrega, resolve o nome (DNS) uma única vez e confere o IP: se qualquer endereço for local (loopback), da rede privada (
10.x,172.16.xa172.31.x,192.168.x), de link-local (inclui o169.254.169.254dos serviços de nuvem), de CGNAT (100.64.xa100.127.x), multicast, de rede privada IPv6 (fc00::/7), ou um IPv4 escondido dentro de IPv6, a conexão nem abre; - liga a conexão direto no IP já conferido, o que impede a troca do IP entre a conferência e a conexão (ataque de "DNS rebinding");
- não segue redirecionamentos;
- lê no máximo 4 KB da resposta e usa limites de tempo de 5 e 10 segundos;
- só aceita
httpsem Produção.
Em webhooks de Teste, o Veltra permite http e o endereço local (localhost) para facilitar o desenvolvimento. As demais proteções valem igual.
11. Como testar#
Passo 1: o botão de teste#
Na página do webhook, clique em Enviar teste. O Veltra envia um evento de teste ao seu endereço, marcado como Teste, e mostra o resultado: "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. O evento de teste tem o tipo webhook.teste e "ambiente": "TESTE", é assinado como uma entrega real e vai ao endereço do próprio webhook; 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. As regras de segurança do endereço valem iguais: se o endereço for interno, o teste volta com entregue: false e o motivo ENDERECO_RECUSADO.
Passo 2: desenvolver na sua máquina com um túnel#
O Veltra precisa alcançar o seu endereço pela internet, então o localhost do seu notebook não serve em Produção. Use uma ferramenta de túnel que dá a você um endereço https público apontando para a sua máquina. Algumas opções gratuitas, sem preferência entre elas: Cloudflare Tunnel (cloudflared), ngrok e localtunnel. Em todas, você roda o seu receptor (por exemplo na porta 3000), abre o túnel para essa porta e cadastra no Veltra o endereço https que a ferramenta mostrar. Cuidado: o endereço do túnel é público, então só o deixe aberto enquanto estiver testando, e confira a assinatura mesmo assim.
Em um webhook de Teste (ambiente de Teste), o http e o localhost são aceitos; o túnel só é necessário quando o seu receptor roda em outra rede.
Passo 3: testar o seu receptor sem o Veltra#
Você pode assinar uma chamada você mesmo e disparar contra o seu receptor local. Com openssl e curl:
# 1. Crie um segredo de teste e mostre-o no formato whsec_ (use só para testes locais)
CHAVE_HEX=$(openssl rand -hex 32)
echo "whsec_$(printf '%s' "$CHAVE_HEX" | xxd -r -p | base64)" # configure este valor no receptor
# 2. Monte o corpo, o id e o 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. Altere um caractere de corpo.json depois de assinar, ou espere mais de 5 minutos, e ele deve responder 401. Esses dois testes provam que a verificação de corpo e de relógio funciona. Rode também o mesmo envio duas vezes e confira que o segundo é reconhecido como repetido (seção 8).
Roteiro de aceite antes de ir para Produção#
- O teste do botão voltou
2xx. - Um corpo alterado é recusado com
401. - Um
webhook-timestampantigo é recusado. - Um
webhook-idrepetido não é processado duas vezes. - O seu servidor responde em poucos segundos mesmo com o banco lento (processamento depois da resposta).
- A troca de segredo foi ensaiada (seção 9) sem perder evento.
12. Monitoramento: o que ver no portal#
Em Integrações > Webhooks:
- Lista: colunas Nome, URL, Eventos, Situação, Última entrega e as ações rápidas (Enviar teste, Pausar ou Retomar, ver detalhes).
- Situação do assinante: Ativo (recebendo), Pausado (você pausou, ou o endereço respondeu
410) e Desativado automaticamente (20 entregas esgotadas seguidas). O contrato também prevê "Com falhas", que o Veltra ainda não aplica. - Resumo: URL, quem recebe, e-mail de contato, raízes de CNPJ, cadência, data de criação e data da última troca de segredo.
- Histórico de entregas: uma linha por entrega, com Horário, Evento, ID da entrega (o identificador da entrega no Veltra, com botão de copiar; é o
{entregaId}da rota de reenvio e não é o cabeçalhowebhook-id), Situação, Código HTTP, Tentativas e Duração. - Fila de falhas: só as entregas esgotadas, com Reenviar e Reenviar todas (N).
Situações de uma entrega: Entregue, Aguardando nova tentativa, Falhou, Teste e Tentativas esgotadas.
Rotina sugerida: olhe a fila de falhas depois de qualquer manutenção do seu servidor; mantenha o e-mail de contato técnico em uma caixa que alguém lê (é por ele que o aviso de desativação chega); e acompanhe do seu lado o tempo de resposta do endpoint.
13. Solução de problemas#
| Sintoma | Causa provável | Correção |
|---|---|---|
Todas as entregas aparecem como "Tentativas esgotadas" com código 401 | Segredo errado, ou corpo alterado antes da verificação | Confira se está usando o segredo do webhook certo, com o prefixo tirado e o Base64 decodificado. Leia o corpo cru. Corrija, depois use Reenviar todas. |
401 só em alguns eventos | Parse e nova serialização do JSON no framework antes de verificar | Configure leitura do corpo em bytes na rota do webhook (seção 5, passo 1). |
| Funcionou ontem, hoje recusa tudo por tempo | Relógio do seu servidor atrasado ou adiantado mais de 5 minutos | Sincronize o relógio (NTP). |
| Verificação falha logo depois de girar o segredo | Receptor ainda com o segredo antigo depois das 24 horas, ou você girou duas vezes | Use o segredo mais recente; se perdeu, gire de novo e guarde. |
| Webhook "Pausado" sem você ter pausado | O endereço respondeu 410 | Corrija o endereço ou faça o servidor parar de responder 410, depois Retomar. |
| Webhook "Desativado automaticamente" | 20 entregas esgotadas seguidas | Corrija a causa. Um webhook desativado some da lista e não volta com Retomar: crie um novo webhook e configure o novo segredo no receptor. |
Entrega esgotada na hora, sem 10 tentativas, com 404 ou 400 | Rotas e erros 4xx não são retentados | Corrija a rota e use Reenviar. Para "tente depois", responda 503. |
| Entrega esgotada na hora, sem código HTTP | Endereço recusado (rede interna), http em Produção, ou redirecionamento | Cadastre um endereço https público e final, sem redirecionamento. |
| Muitas retentativas com "Falhou" e código vazio | Seu servidor não responde em 10 segundos, DNS ou certificado HTTPS inválido | Responda rápido e processe depois; confira DNS e a cadeia do certificado. |
| O mesmo evento chega duas vezes | Normal: resposta perdida, retentativa ou reenvio | Deduplique pelo webhook-id (seção 8). |
| Eventos chegam fora de ordem | Normal: uma retentativa foi ultrapassada | Use ocorridoEm para ordenar. |
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; confira a cadência escolhida. |
| Não chega nenhum evento, e o assinante está Ativo | Eventos de outro tipo, de outro ambiente (Teste e Produção são separados) ou o tipo ainda não é disparado | Confira os tipos marcados e a coluna "Gerado hoje pelo Veltra" (seção 3). |
| O evento de teste voltou "O teste falhou" | Endereço inalcançável a partir da internet | Use um túnel ou publique o endereço (seção 11). |
| Quero o PDF e ele não vem no webhook | O webhook leva só o comprovanteId | Baixe pela API com a chave de integração (seção 14). |
14. Relação com a API de consulta#
Mapa interativo de todas as rotas de integração: Swagger da API. O guia explicado de cada rota está em
api-por-rota.mdx.
O ADR 0013 fixa a regra de paridade: o que o portal mostra existe também na API de leitura, nos eventos de webhook e na exportação. Na prática:
- Os eventos de webhook e os eventos da consulta
GET /v1/saida/eventosusam o mesmo envelope e o mesmoid. O que chega por um caminho pode ser conferido pelo outro. - O webhook é o caminho rápido; a consulta é o caminho completo e reprocessável. Se o seu servidor ficou fora do ar por mais de 72 horas, ou se você perdeu um evento, a consulta recupera.
- Para detalhes que o webhook não traz (o documento inteiro, o PDF, o pacote de evidências, o XML do evento 110180), use a API de leitura com a chave do TMS. A referência de leitura lista as rotas.
Receita recomendada (rede de segurança):
- Receba pelo webhook e processe com deduplicação.
- A cada hora (ou a cada dia, conforme o seu volume), chame
GET /v1/saida/eventos?alteradoDesde=CURSOR_GUARDADO_AQUIe processe o que ainda não viu, comparando peloiddo evento. - Guarde o
proximoCursordevolvido.
curl -H "X-Veltra-Api-Key: SUA_CHAVE_AQUI" \
"https://SEU_ENDERECO_AQUI/v1/saida/eventos?alteradoDesde=CURSOR_GUARDADO_AQUI&limite=200"Lembretes da consulta: o cursor é opaco (não monte nem edite); o Veltra só devolve eventos alterados há pelo menos 5 segundos; e as chaves de consulta de embarcador e destinatário não recebem posicao.lote.
15. Resumo para colar na parede#
- O webhook é um
POSTdo Veltra para o seu endereço. Responda2xxrápido, 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 (1 min, 5 min, 30 min, 2 h, 12 h, 12 h...) por até 72 horas.4xx(menos429) e3xxnão são retentados;410pausa o webhook.- Duplicata é normal: deduplique pelo
webhook-id. Ordem não é garantida. - Giro de segredo: 24 horas com duas assinaturas.
- Rede de segurança: consulta incremental com
alteradoDesde.