Skip to Content
API de integração — v1
Webhooks

Webhooks

Webhooks notificam seu sistema em tempo real quando algo acontece com seus documentos fiscais — sem precisar ficar consultando a API.

Eventos

EventoQuando dispara
document.authorizedA NF-e/NFC-e/NFS-e foi autorizada pela SEFAZ/prefeitura
document.rejectedA emissão foi rejeitada pelo fisco
document.cancelledUm documento autorizado foi cancelado
document.inutilizedUm intervalo de numeração foi inutilizado

Separar produção de homologação

O campo environments do endpoint decide quais notas chegam naquela URL:

# staging: só homologação curl -X POST https://api.conttrole.io/v1/webhooks \ -H "Authorization: Bearer ck_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://staging.suaempresa.com/webhooks/conttrole", "environments": ["HOMOLOGATION"] }' # produção: só notas com valor fiscal curl -X POST https://api.conttrole.io/v1/webhooks \ -H "Authorization: Bearer ck_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.suaempresa.com/webhooks/conttrole", "environments": ["PRODUCTION"] }'

Sem esse filtro, o seu ambiente de testes recebe nota real e o de produção recebe nota de homologação, que não vale nada fiscalmente.

Vazio ou omitido = recebe os dois. Endpoint já cadastrado continua recebendo tudo — nada muda até você escolher. É a mesma convenção do events.

Aceita também no PATCH, e aparece como Ambiente no formulário do painel. Todo evento de documento traz environment no corpo, então dá para distinguir mesmo recebendo os dois numa URL só.

Separar por tipo de documento

documentTypes funciona igual, para quem trata mercadoria e serviço em sistemas diferentes:

{ "url": "https://servicos.suaempresa.com/webhooks", "documentTypes": ["NFSE"] }

Valores: NFE, NFCE, NFSE. Vazio ou omitido = todos.

Os filtros se combinam com E: um endpoint com environments: ["PRODUCTION"] e documentTypes: ["NFSE"] recebe só NFS-e de produção.

document.inutilized não tem tipo nem ambiente — é faixa de numeração, não nota. Por isso ele ignora os dois filtros e chega em todos os endpoints inscritos no evento. Silenciar por falta de informação seria pior que entregar demais.

Você recebe o desfecho, não a tentativa

Quando a comunicação com a SEFAZ falha — fora do ar, timeout, instabilidade de rede —, a emissão não vira document.rejected. O documento volta para rascunho e é reemitido automaticamente pelo Conttrole, sem você fazer nada.

Nenhum evento é enviado durante essas tentativas. O motivo é prático: uma nota que falhou por rede às 10h00 costuma ser autorizada às 10h10, e um document.rejected no meio faria seu sistema estornar a venda, liberar o estoque ou cancelar o pedido — para uma nota que acabou saindo.

Você recebe evento quando existe um resultado real:

SituaçãoEvento
Autorizada pela SEFAZ/prefeituradocument.authorized
Rejeitada pelo fisco (dado inválido, regra fiscal)document.rejected na hora
Falha de comunicação, com tentativas restantesnenhum — aguarde
Falha de comunicação, tentativas esgotadasdocument.rejected

Rejeição do fisco dispara imediatamente: o problema está no conteúdo da nota e reenviar igual não resolve, então não há o que esperar.

Não trate a ausência de evento como sucesso nem como falha. Uma nota em reemissão automática ainda não tem desfecho. Se o seu fluxo precisa saber o estado agora, consulte GET /v1/fiscal-documents/{id} — o campo status é a fonte de verdade a qualquer momento.

Cadastrar um endpoint

Crie um endpoint via API (escopo webhooks:write) ou pela tela Webhooks no painel. Selecione os eventos desejados — sem nenhum selecionado, o endpoint recebe todos.

curl -X POST https://api.conttrole.io/v1/webhooks \ -H "Authorization: Bearer ck_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://seu-sistema.com/webhooks/conttrole", "description": "Integração ERP", "events": ["DOCUMENT_AUTHORIZED", "DOCUMENT_CANCELLED"] }'

A resposta traz o secret de assinatura uma única vez — guarde-o.

A URL precisa ser https e apontar para um host público — endereços internos/privados (localhost, IPs de rede interna, metadata de cloud) são recusados por segurança (proteção contra SSRF).

Verificação de saúde no cadastro

Antes de salvar, a Conttrole faz um POST de verificação assinado para a URL informada (um evento webhook.ping, assinado igual aos eventos reais). O endpoint precisa responder 2xx em até 10s — caso contrário o cadastro é recusado com HTTP 422 (webhook_unreachable). Garanta que seu endpoint já está no ar e aceita o POST (validando a assinatura) antes de cadastrá-lo.

O ping tem o formato:

{ "id": "evt_...", "type": "webhook.ping", "createdAt": "2026-06-15T12:00:00.000Z", "data": { "message": "Verificação de saúde do endpoint (Conttrole)." } }

A mesma verificação roda ao reativar um endpoint ou ao trocar a URL de um endpoint ativo (PATCH /v1/webhooks/{id}).

Formato da entrega

Cada evento é um POST JSON no seu endpoint:

{ "id": "evt_9f3a...", "type": "document.authorized", "createdAt": "2026-06-10T12:00:00.000Z", "data": { "documentId": "ckv...", "externalId": "PEDIDO-8842", "type": "NFE", "series": 1, "number": 123, "accessKey": "3526...", "authorizationDate": "2026-06-10T11:59:58.000Z", "protocolNumber": "135...", "rejectionReason": null, "cancellationReason": null, "totalValue": "199.90" } }

O campo environment (PRODUCTION | HOMOLOGATION) diz de qual ambiente a nota é. Veja também Separar produção de homologação, abaixo, para receber cada ambiente numa URL diferente.

Use o externalId para casar o evento com o seu pedido. É o mesmo valor que você informou ao criar a nota (POST /v1/fiscal-documents), devolvido em todos os eventos do documento. Sem ele você precisaria guardar o nosso documentId numa tabela de-para só para entender o próprio webhook. Vem null quando a nota não foi criada com um.

O mesmo corpo sai nos quatro eventos de documento — document.authorized, document.rejected, document.cancelled — variando apenas os campos que fazem sentido em cada um: rejectionReason na rejeição, cancellationReason no cancelamento. Não há dois formatos para o mesmo evento, e o reenvio manual entrega exatamente o que a emissão entregou.

document.inutilized é o único com forma própria, porque não se refere a uma nota: traz inutilizationId, series, startNumber, endNumber e year.

O contrato completo está no OpenAPI, no campo payload de GET /v1/webhooks/{id}/deliveries — é o mesmo objeto que enviamos ao seu endpoint. Campos novos podem aparecer com o tempo; ignore os que não conhecer.

Headers enviados:

HeaderConteúdo
X-Webhook-EventO tipo do evento (ex: document.authorized)
X-Webhook-DeliveryId único da entrega
X-Webhook-Signaturet=<timestamp>,v1=<assinatura>

Headers antigos em depreciação. Até 01/02/2027 cada entrega também traz X-Conttrole-Event, X-Conttrole-Delivery e X-Conttrole-Signature, com exatamente os mesmos valores. Se o seu código lê os antigos, ele continua funcionando — migre para os X-Webhook-* antes dessa data, quando os antigos deixam de ser enviados.

Validar a assinatura

A assinatura é o HMAC-SHA256 de "<timestamp>.<corpo cru>", usando o secret do endpoint. Recalcule e compare antes de confiar no payload.

import { createHmac, timingSafeEqual } from "crypto"; function verify(rawBody: string, header: string, secret: string): boolean { const parts = Object.fromEntries( header.split(",").map((kv) => kv.split("=")), ); const expected = createHmac("sha256", secret) .update(`${parts.t}.${rawBody}`) .digest("hex"); return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1)); }

Entregas e retries

  • Respostas 2xx marcam a entrega como entregue.
  • Qualquer outra resposta (ou timeout de 10s) agenda novas tentativas com backoff exponencial (até 5 tentativas). Esgotadas, a entrega fica falha.
  • Reenvio automático: ao esgotar as tentativas imediatas, a entrega é reagendada automaticamente com atraso crescente (até ~24h), caso seu endpoint volte do ar — sem você fazer nada.
  • Reenvio manual: reenvie uma entrega específica via POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver ou pelo botão Reenviar na tela Webhooks do painel.
  • Consulte as entregas recentes em GET /v1/webhooks/{id}/deliveries ou na tela Webhooks do painel. Cada entrega traz o payload — o corpo exato que enviamos, o mesmo sobre o qual a assinatura foi calculada — ao lado de responseStatus e responseBody. Os dois lados da conversa, para você fechar o diagnóstico sem precisar abrir o painel.
  • Use o id do evento (evt_...) para deduplicar no seu lado — a mesma entrega pode chegar mais de uma vez em cenários de retry.

Ver cada tentativa

responseStatus, responseBody e errorMessage na entrega são o resumo da última tentativa — cada nova sobrescreve a anterior. Para ver o que cada uma recebeu, use o attemptLog da mesma resposta:

{ "id": "d_9f3a...", "status": "SUCCEEDED", "attempts": 2, "attemptLog": [ { "attempt": 1, "signedAt": 1786000000, "signature": "aaaa1111...", "responseStatus": 502, "responseBody": "<html>502 Bad Gateway</html>", "errorMessage": "HTTP 502", "durationMs": 8123, "createdAt": "2026-06-10T12:00:05.000Z" }, { "attempt": 2, "signedAt": 1786000300, "signature": "bbbb2222...", "responseStatus": 200, "responseBody": "ok", "errorMessage": null, "durationMs": 142, "createdAt": "2026-06-10T12:05:00.000Z" } ] }

O corpo enviado não se repete por tentativa: é o mesmo payload em todas. O que muda é a assinatura — o HMAC cobre <signedAt>.<payload>, então cada tentativa tem a sua. É com ela que você encontra a requisição correspondente no log do seu servidor.

durationMs separa “meu servidor recusou na hora” de “estourou o timeout de 10s”, que costumam ter causas bem diferentes.

Entregas anteriores a este recurso vêm com attemptLog: [] — o histórico não é retroativo. O resumo da última tentativa continua nos campos de sempre.

Nota que nunca gerou entrega

O reenvio acima age sobre uma entrega que existe. Ele não cobre o caso em que nenhuma entrega foi criada para a nota — o mais comum sendo a nota ter sido emitida antes de você cadastrar o endpoint.

Para esse caso, dispare o webhook a partir da própria nota (escopo webhooks:write):

curl -X POST https://api.conttrole.io/v1/fiscal-documents/{id}/webhook \ -H "Authorization: Bearer ck_live_..."
{ "dispatched": 2 }

dispatched é quantas entregas foram criadas — uma por endpoint ativo inscrito no evento.

O evento enviado é o do estado atual da nota: uma nota cancelada dispara document.cancelled, não document.authorized.

RespostaQuando
202Entregas criadas e enfileiradas
404Nota inexistente ou de outra empresa
422 document_without_outcomeNota ainda em rascunho ou processando
422 no_active_webhook_endpointNenhum endpoint ativo inscrito no evento
429Limite de 60 disparos/min por empresa

Cada chamada gera um id de evento novo. Diferente do retry automático, o seu endpoint não consegue deduplicar este disparo contra a entrega original — a dedupe por evt_... não ajuda aqui. Use de forma deliberada, para corrigir notas que ficaram sem aviso, e não em laço sobre a base inteira: quem recebe a rajada é o seu servidor.

Também dá para fazer isso pelo painel: abra a nota e use o botão Enviar webhook, ao lado do status da última entrega.

Backfill de um período

Para muitas notas — o caso de ter cadastrado o endpoint depois de meses operando —, não faça laço sobre o endpoint acima. Use o backfill, que processa o período inteiro numa chamada, em ritmo controlado:

curl -X POST https://api.conttrole.io/v1/webhooks/backfill \ -H "Authorization: Bearer ck_live_..." \ -H "Content-Type: application/json" \ -d '{ "from": "2026-07-01T00:00:00Z", "to": "2026-07-31T23:59:59Z", "dryRun": true }'

Comece sempre com dryRun: true. Ele conta quantas notas seriam atingidas sem disparar nada — cada uma vira um POST no seu servidor, e “o último mês” pode ser 30 ou 30 mil:

{ "dryRun": true, "matched": 320, "truncated": false }

Confirmado o alcance, repita sem o dryRun. A resposta é imediata (202); o trabalho roda em segundo plano:

{ "runId": "run_abc123", "matched": 320, "truncated": false }
CampoEfeito
from / toPeríodo pela data de emissão da nota (ISO 8601)
types["NFE","NFCE","NFSE"] — omitido, pega todos
onlyMissingDefault true: pula notas que já geraram alguma entrega
environmentPRODUCTION ou HOMOLOGATION — omitido, pega ambos
dryRunSó conta, não dispara

onlyMissing: true torna a chamada repetível. Rodar o mesmo backfill duas vezes não duplica o que já saiu. Use false só quando as entregas existem mas falharam — por exemplo, o seu servidor esteve fora do ar e você quer forçar tudo de novo.

Só notas com desfecho entram (autorizada, rejeitada, cancelada); rascunhos são ignorados. O evento de cada uma é o do seu estado atual.

Teto de 5.000 notas por execução. Acima disso a resposta traz truncated: true — estreite o período e rode em partes. O limite existe para proteger o seu servidor, não o nosso.

O backfill é limitado a 5 chamadas por minuto por empresa, bem abaixo do disparo unitário, porque cada uma vale milhares de entregas.

Desativação automática

Para não acumular entregas fadadas ao fracasso, um endpoint é desativado automaticamente (isActive: false) após 5 entregas consecutivas com falha (cada uma já tentada por até ~24h). Quando isso acontece, o campo disabledReason explica o motivo e disabledAt registra quando. Uma entrega bem-sucedida zera o contador (consecutiveFailures).

Reativar (switch on/off)

Na tela Webhooks do painel há um switch on/off por endpoint. Ao ligar, a Conttrole refaz o POST de verificação — só reativa se o endpoint responder 2xx (senão mostra o erro e mantém desativado). Via API, reative com:

curl -X PATCH https://api.conttrole.io/v1/webhooks/{id} \ -H "Authorization: Bearer ck_live_..." \ -H "Content-Type: application/json" \ -d '{ "isActive": true }'

Reativar zera o contador de falhas. Se o endpoint ainda não responde 2xx, a reativação é recusada com 422 (webhook_unreachable).

Testar

Envie um evento de teste (webhook.test) sem emitir nada:

curl -X POST https://api.conttrole.io/v1/webhooks/{id}/test \ -H "Authorization: Bearer ck_live_..."

A referência completa de todos os endpoints está em Referência da API.

Last updated on