Webhooks
Webhooks notificam seu sistema em tempo real quando algo acontece com seus documentos fiscais — sem precisar ficar consultando a API.
Eventos
| Evento | Quando dispara |
|---|---|
document.authorized | A NF-e/NFC-e/NFS-e foi autorizada pela SEFAZ/prefeitura |
document.rejected | A emissão foi rejeitada pelo fisco |
document.cancelled | Um documento autorizado foi cancelado |
document.inutilized | Um 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.inutilizednã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ção | Evento |
|---|---|
| Autorizada pela SEFAZ/prefeitura | document.authorized |
| Rejeitada pelo fisco (dado inválido, regra fiscal) | document.rejected na hora |
| Falha de comunicação, com tentativas restantes | nenhum — aguarde |
| Falha de comunicação, tentativas esgotadas | document.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 campostatusé 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
externalIdpara 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 nossodocumentIdnuma tabela de-para só para entender o próprio webhook. Vemnullquando 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:
| Header | Conteúdo |
|---|---|
X-Webhook-Event | O tipo do evento (ex: document.authorized) |
X-Webhook-Delivery | Id único da entrega |
X-Webhook-Signature | t=<timestamp>,v1=<assinatura> |
Headers antigos em depreciação. Até 01/02/2027 cada entrega também traz
X-Conttrole-Event,X-Conttrole-DeliveryeX-Conttrole-Signature, com exatamente os mesmos valores. Se o seu código lê os antigos, ele continua funcionando — migre para osX-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}/redeliverou pelo botão Reenviar na tela Webhooks do painel. - Consulte as entregas recentes em
GET /v1/webhooks/{id}/deliveriesou na tela Webhooks do painel. Cada entrega traz opayload— o corpo exato que enviamos, o mesmo sobre o qual a assinatura foi calculada — ao lado deresponseStatuseresponseBody. Os dois lados da conversa, para você fechar o diagnóstico sem precisar abrir o painel. - Use o
iddo 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.
| Resposta | Quando |
|---|---|
202 | Entregas criadas e enfileiradas |
404 | Nota inexistente ou de outra empresa |
422 document_without_outcome | Nota ainda em rascunho ou processando |
422 no_active_webhook_endpoint | Nenhum endpoint ativo inscrito no evento |
429 | Limite de 60 disparos/min por empresa |
Cada chamada gera um
idde evento novo. Diferente do retry automático, o seu endpoint não consegue deduplicar este disparo contra a entrega original — a dedupe porevt_...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 }| Campo | Efeito |
|---|---|
from / to | Período pela data de emissão da nota (ISO 8601) |
types | ["NFE","NFCE","NFSE"] — omitido, pega todos |
onlyMissing | Default true: pula notas que já geraram alguma entrega |
environment | PRODUCTION ou HOMOLOGATION — omitido, pega ambos |
dryRun | Só conta, não dispara |
onlyMissing: truetorna a chamada repetível. Rodar o mesmo backfill duas vezes não duplica o que já saiu. Usefalsesó 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.