Webhooks
Receba um aviso HTTP quando um CT-e ou um MDF-e muda de situação, em vez de consultar a API a cada instante. Você cadastra a URL do seu sistema, escolhe os eventos e confere a assinatura de cada entrega.
Visão geral
O webhook é um aviso: ele diz que o documento mudou, e o polling continua sendo a fonte de verdade. Uma entrega pode atrasar, chegar repetida ou fora de ordem, e pode falhar depois de todas as tentativas. Por isso, trate o aviso como gatilho para consultar o documento (GET /services/v1/integracoes/ctes/{id} ou .../mdfes/{id}) e mantenha o polling como rede de segurança.
Como funciona:
- Você cadastra o webhook com a URL, os eventos e, se quiser, os CNPJs.
- A resposta traz o
segredo(whsec_…). Ele aparece só no cadastro e na rotação: guarde-o num cofre de segredos. - A cada evento, enviamos um
POSTcom o corpo em JSON e os cabeçalhos de assinatura. - Seu sistema confere a assinatura e responde
2xx. Sem2xx, há novas tentativas.
As rotas de gestão exigem o escopo webhooks:gerenciar e o recurso de webhooks no contrato da conta; sem ele, 403 feature_indisponivel (detalhes).
Rotas
Os caminhos são completos, a partir do domínio https://api.logmonitor.com.br. As escritas levam assinatura e Idempotency-Key. O {id} é o UUID do webhook; em reenviar, é o UUID da entrega.
| Método | Caminho | Escopo |
|---|---|---|
POST | /services/v1/integracoes/webhooks | webhooks:gerenciar |
GET | /services/v1/integracoes/webhooks | webhooks:gerenciar |
GET | /services/v1/integracoes/webhooks/{id} | webhooks:gerenciar |
PUT | /services/v1/integracoes/webhooks/{id} | webhooks:gerenciar |
DELETE | /services/v1/integracoes/webhooks/{id} | webhooks:gerenciar |
POST | /services/v1/integracoes/webhooks/{id}/segredo/rotacionar | webhooks:gerenciar |
POST | /services/v1/integracoes/webhooks/{id}/testar | webhooks:gerenciar |
GET | /services/v1/integracoes/webhooks/{id}/entregas | webhooks:gerenciar |
POST | /services/v1/integracoes/webhooks/entregas/{id}/reenviar | webhooks:gerenciar |
Eventos
| Evento | Quando |
|---|---|
cte.autorizado | A SEFAZ autorizou o CT-e. |
cte.rejeitado | O CT-e foi rejeitado ou denegado; dados.status diz qual. |
cte.cancelado | O cancelamento do CT-e foi homologado. |
cte.evento_rejeitado | A SEFAZ recusou o cancelamento; veja dados.motivo_cancelamento_recusado. |
mdfe.autorizado | A SEFAZ autorizou o MDF-e. |
mdfe.rejeitado | O MDF-e foi rejeitado ou denegado; dados.status diz qual. |
mdfe.cancelado | O cancelamento do MDF-e foi homologado. |
mdfe.encerrado | O encerramento do MDF-e foi homologado. |
mdfe.evento_rejeitado | A SEFAZ recusou o cancelamento; veja dados.motivo_cancelamento_recusado. |
webhook.teste | Só no teste. |
Cadastrar
POST /services/v1/integracoes/webhooks · webhooks:gerenciar
json
{
"url": "https://seu-sistema.exemplo/webhooks/subcontratei",
"eventos": [
"cte.autorizado",
"cte.rejeitado",
"mdfe.autorizado",
"mdfe.encerrado"
],
"cnpjs": [
"00000000000000"
]
}url: obrigatória,https, até 500 caracteres. Precisa resolver para um IP público: endereço privado (10.x,172.16.xa172.31.x,192.168.x), local (127.x) ou link-local (169.254.x,fe80::) devolve422. O DNS é conferido de novo a cada entrega.eventos: obrigatório, ao menos um dos eventos.cnpjs: opcional, de 1 a 500 CNPJs de 14 dígitos. Sem ele, o webhook recebe os eventos da conta inteira.
Uma credencial restrita a cnpjs só cadastra e enxerga webhooks com CNPJs dela; fora disso, 403 empresa_fora_da_integracao.
Resposta 201 (data): o webhook, com o segredo (whsec_ e 40 caracteres), que só vem agora e na rotação.
json
{
"id": "00000000-0000-0000-0000-000000000000",
"url": "https://seu-sistema.exemplo/webhooks/subcontratei",
"eventos": [
"cte.autorizado",
"cte.rejeitado",
"mdfe.autorizado",
"mdfe.encerrado"
],
"cnpjs": [
"00000000000000"
],
"ativo": true,
"desativado_em": null,
"motivo_desativacao": null,
"segredo_anterior_ate": null,
"criado_em": "2026-10-05T14:30:00-04:00",
"atualizado_em": "2026-10-05T14:30:00-04:00",
"segredo": "whsec_…"
}Erros: 403 empresa_fora_da_integracao; 422 de validação (inclusive URL que não resolve para IP público).
Listar
GET /services/v1/integracoes/webhooks · webhooks:gerenciar
Resposta 200 (data): a lista dos webhooks visíveis à credencial, cada um como no detalhe.
Consultar
GET /services/v1/integracoes/webhooks/{id} · webhooks:gerenciar
Campos do webhook:
id,url,eventosecnpjs: como no cadastro.ativo:falsequando o webhook está desativado.desativado_ememotivo_desativacao: preenchidos quando um410 Gonedesativou o webhook.segredo_anterior_ate: preenchido só durante a rotação.criado_emeatualizado_em.
O segredo não aparece aqui. Erros: 404.
Alterar e reativar
PUT /services/v1/integracoes/webhooks/{id} · webhooks:gerenciar
Aceita url, eventos, cnpjs e ativo, com as mesmas regras do cadastro. Mande "ativo": true para reativar um webhook desativado por 410: o motivo da desativação é limpo.
json
{
"ativo": true
}Resposta 200 (data): o webhook atualizado. Erros: 404; 403 empresa_fora_da_integracao; 422.
Excluir
DELETE /services/v1/integracoes/webhooks/{id} · webhooks:gerenciar
Remove o webhook e para as entregas. Erros: 404.
Rotacionar o segredo
POST /services/v1/integracoes/webhooks/{id}/segredo/rotacionar · webhooks:gerenciar
Gera um segredo novo e o devolve em segredo, uma única vez. O segredo anterior continua assinando por 24 h (segredo_anterior_ate): nesse período o cabeçalho traz as duas assinaturas, t=…,v1=<novo>,v1=<anterior>. Como a conferência aceita qualquer v1 que confira, troque o segredo no seu sistema sem perder entregas.
Testar
POST /services/v1/integracoes/webhooks/{id}/testar · webhooks:gerenciar
Envia agora um webhook.teste, com dados assim:
json
{
"mensagem": "Teste do webhook."
}É uma tentativa só, sem novas tentativas. A resposta (data) é a entrega, com o ultimo_status_http da sua URL.
Entregas
GET /services/v1/integracoes/webhooks/{id}/entregas · webhooks:gerenciar
Parâmetros de consulta, opcionais:
status:pendente,entregueoufalhou.por_pagina: de 1 a 100 (padrão 15). A página vem empage.
Resposta 200 (data): a página, com as entregas em data.data, mais current_page, per_page, total e last_page.
json
{
"id": "00000000-0000-0000-0000-000000000000",
"evento_id": "00000000-0000-7000-8000-000000000000",
"evento": "cte.autorizado",
"status": "pendente",
"tentativas": 2,
"proxima_tentativa_em": "2026-10-05T14:41:00-04:00",
"ultimo_status_http": 503,
"ultimo_erro": null,
"entregue_em": null,
"criado_em": "2026-10-05T14:30:00-04:00"
}proxima_tentativa_em só vem com status pendente.
Reenviar
POST /services/v1/integracoes/webhooks/entregas/{id}/reenviar · webhooks:gerenciar
O {id} é o UUID da entrega. Ela volta a pendente, as tentativas recomeçam do zero e o evento é o mesmo, com o mesmo X-Subcontratei-Evento-Id: seu sistema reconhece a repetição pelo id do evento. Erros: 404.
Corpo enviado
Cada entrega é um POST com JSON:
json
{
"id": "00000000-0000-7000-8000-000000000000",
"tipo": "cte.autorizado",
"criado_em": "2026-10-05T14:30:05-04:00",
"dados": {
"id": "00000000-0000-0000-0000-000000000000",
"modelo": "cte",
"chave": "00000000000000000000000000000000000000000000",
"cnpj_emitente": "00000000000000",
"serie": "1",
"numero": 1234,
"ambiente": "homologacao",
"status": "AUTORIZADO",
"codigo_status_sefaz": 100,
"motivo_sefaz": "Autorizado o uso do CT-e",
"protocolo": "000000000000000",
"motivo_cancelamento_recusado": null,
"atualizado_em": "2026-10-05T14:30:04-04:00"
}
}id: o id do evento (UUID v7), igual em toda tentativa e reenvio.tipo: o evento.dados.modelo:cteoumdfe.dados.idé o UUID do documento, o mesmo das rotas de CT-e e MDF-e.dados.motivo_cancelamento_recusado: preenchido nos eventos*.evento_rejeitado.
Cabeçalhos
| Cabeçalho | Valor |
|---|---|
Content-Type | application/json |
X-Subcontratei-Evento | O tipo do evento, como cte.autorizado. |
X-Subcontratei-Evento-Id | Igual ao id do corpo; o mesmo em toda tentativa e reenvio. |
X-Subcontratei-Assinatura | t=<unix>,v1=<hex> |
O v1 é o HMAC-SHA256 em hexadecimal, com o segredo do webhook, de "<t>.<corpo cru>": o instante t, um ponto e o corpo exatamente como chegou.
Conferir a assinatura
- Leia o corpo cru, antes de qualquer parse de JSON.
- Separe o
te todos osv1do cabeçalho. - Recuse se
|agora − t|passar de 300 s. - Calcule o HMAC-SHA256 de
"<t>.<corpo cru>"com o seu segredo. - Compare em tempo constante. Aceite se qualquer
v1conferir. - Responda
2xxem até 10 s (o tempo limite) e processe depois. Se o id do evento já foi processado, responda2xxe ignore.
Use o corpo cru
Reserializar o JSON muda espaços e a ordem das chaves, e a assinatura deixa de conferir. Nunca calcule o HMAC sobre o objeto já lido.
Exemplo em Node.js
Com Express, express.raw entrega o corpo como Buffer:
js
import crypto from 'node:crypto'
import express from 'express'
const SEGREDO = process.env.WEBHOOK_SEGREDO // whsec_…
const TOLERANCIA = 300 // segundos
const app = express()
app.post('/webhooks/subcontratei', express.raw({ type: 'application/json' }), (req, res) => {
const cabecalho = req.get('X-Subcontratei-Assinatura') || ''
const partes = cabecalho.split(',').map((p) => p.trim().split('='))
const t = partes.find(([k]) => k === 't')?.[1]
const v1s = partes.filter(([k]) => k === 'v1').map(([, v]) => v)
if (!t || v1s.length === 0 || Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCIA) {
return res.sendStatus(400)
}
const esperado = crypto.createHmac('sha256', SEGREDO).update(`${t}.`).update(req.body).digest()
const confere = v1s.some((v1) => {
const recebido = Buffer.from(v1, 'hex')
return recebido.length === esperado.length && crypto.timingSafeEqual(recebido, esperado)
})
if (!confere) return res.sendStatus(400)
const evento = JSON.parse(req.body.toString('utf8'))
// Se evento.id já foi processado, responda 2xx e ignore.
// Enfileire o trabalho e responda logo.
res.sendStatus(200)
})
app.listen(3000)Exemplo em PHP
php
<?php
$segredo = getenv('WEBHOOK_SEGREDO'); // whsec_…
$tolerancia = 300; // segundos
$corpo = file_get_contents('php://input'); // corpo cru, antes do json_decode
$cabecalho = $_SERVER['HTTP_X_SUBCONTRATEI_ASSINATURA'] ?? '';
$t = null;
$v1s = [];
foreach (explode(',', $cabecalho) as $parte) {
[$chave, $valor] = array_pad(explode('=', trim($parte), 2), 2, '');
if ($chave === 't') $t = $valor;
if ($chave === 'v1') $v1s[] = $valor;
}
if ($t === null || !$v1s || abs(time() - (int) $t) > $tolerancia) {
http_response_code(400);
exit;
}
$esperado = hash_hmac('sha256', $t . '.' . $corpo, $segredo);
$confere = false;
foreach ($v1s as $v1) {
if (hash_equals($esperado, $v1)) $confere = true;
}
if (!$confere) {
http_response_code(400);
exit;
}
$evento = json_decode($corpo, true);
// Se $evento['id'] já foi processado, responda 2xx e ignore.
// Enfileire o trabalho e responda logo.
http_response_code(200);Entrega e novas tentativas
- Qualquer resposta
2xxconta como entregue. Redirecionamento não é seguido. - Qualquer outra resposta, ou falha de conexão, gera nova tentativa depois de 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h e 24 h.
- Um
Retry-Aftermaior que a espera da vez vale no lugar dela, até 24 h. - Passada a última tentativa, a entrega fica
falhou. Você pode reenviá-la. 410 Gonedesativa o webhook (ativo: false, com omotivo_desativacao). Para voltar, reative comativo: true.- A ordem não é garantida: compare o
dados.atualizado_emou consulte o documento.
Boas práticas
- Idempotência pelo id do evento: guarde o
iddo evento (ou oX-Subcontratei-Evento-Id) já processado. Se ele voltar, responda2xxe ignore. - Responda
2xxrápido: o limite é 10 s. Grave o evento numa fila e processe depois. - Não dependa da ordem: um
cte.autorizadopode chegar depois de umcte.cancelado. Usedados.atualizado_emou consulte o documento para saber o estado atual. - Mantenha o polling: ele cobre entregas que falharam ou que você perdeu.
- Sempre confira a assinatura antes de agir sobre o corpo.