Pular para o conteúdo

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:

  1. Você cadastra o webhook com a URL, os eventos e, se quiser, os CNPJs.
  2. A resposta traz o segredo (whsec_…). Ele aparece só no cadastro e na rotação: guarde-o num cofre de segredos.
  3. A cada evento, enviamos um POST com o corpo em JSON e os cabeçalhos de assinatura.
  4. Seu sistema confere a assinatura e responde 2xx. Sem 2xx, 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.

Eventos ​

EventoQuando
cte.autorizadoA SEFAZ autorizou o CT-e.
cte.rejeitadoO CT-e foi rejeitado ou denegado; dados.status diz qual.
cte.canceladoO cancelamento do CT-e foi homologado.
cte.evento_rejeitadoA SEFAZ recusou o cancelamento; veja dados.motivo_cancelamento_recusado.
mdfe.autorizadoA SEFAZ autorizou o MDF-e.
mdfe.rejeitadoO MDF-e foi rejeitado ou denegado; dados.status diz qual.
mdfe.canceladoO cancelamento do MDF-e foi homologado.
mdfe.encerradoO encerramento do MDF-e foi homologado.
mdfe.evento_rejeitadoA SEFAZ recusou o cancelamento; veja dados.motivo_cancelamento_recusado.
webhook.testeSó 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.x a 172.31.x, 192.168.x), local (127.x) ou link-local (169.254.x, fe80::) devolve 422. 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, eventos e cnpjs: como no cadastro.
  • ativo: false quando o webhook está desativado.
  • desativado_em e motivo_desativacao: preenchidos quando um 410 Gone desativou o webhook.
  • segredo_anterior_ate: preenchido só durante a rotação.
  • criado_em e atualizado_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, entregue ou falhou.
  • por_pagina: de 1 a 100 (padrão 15). A página vem em page.

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: cte ou mdfe. 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çalhoValor
Content-Typeapplication/json
X-Subcontratei-EventoO tipo do evento, como cte.autorizado.
X-Subcontratei-Evento-IdIgual ao id do corpo; o mesmo em toda tentativa e reenvio.
X-Subcontratei-Assinaturat=<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 ​

  1. Leia o corpo cru, antes de qualquer parse de JSON.
  2. Separe o t e todos os v1 do cabeçalho.
  3. Recuse se |agora − t| passar de 300 s.
  4. Calcule o HMAC-SHA256 de "<t>.<corpo cru>" com o seu segredo.
  5. Compare em tempo constante. Aceite se qualquer v1 conferir.
  6. Responda 2xx em até 10 s (o tempo limite) e processe depois. Se o id do evento já foi processado, responda 2xx e 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 2xx conta 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-After maior 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 Gone desativa o webhook (ativo: false, com o motivo_desativacao). Para voltar, reative com ativo: true.
  • A ordem não é garantida: compare o dados.atualizado_em ou consulte o documento.

Boas práticas ​

  • Idempotência pelo id do evento: guarde o id do evento (ou o X-Subcontratei-Evento-Id) já processado. Se ele voltar, responda 2xx e ignore.
  • Responda 2xx rápido: o limite é 10 s. Grave o evento numa fila e processe depois.
  • Não dependa da ordem: um cte.autorizado pode chegar depois de um cte.cancelado. Use dados.atualizado_em ou 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.

Documentação da API da Subcontratei.