Webhooks
Event catalogue, delivery headers, HMAC-SHA256 signature, per-document payloads, retries and circuit breaker of TF Fiscal webhooks.
Delivery model
The platform pushes document results and verification verdicts to your server so you do not need to poll.
- One callback URL per application, registered with Register webhook. Calling the endpoint again overwrites the URL and the
token. NF-e, CT-e and DC-e results and the NF-e verification verdict are all delivered to this URL. - At-least-once delivery. A retry after a timeout can reach you even though the original attempt was processed, and the same event can be fanned out to several targets. Deduplicate on the event id (
X-Tffiscal-Event-Idheader, andevent_idin enveloped payloads): it is identical across every attempt of the same event. - Every delivery is an HTTP
POSTwith a JSON body (Content-Type: application/json; charset=utf-8) to the registered URL. - Test and production documents follow the same contract, signature and retry schedule, see Environments.
Event catalogue
| Event | Triggered when | Payload |
|---|---|---|
invoice.authorized | NF-e authorized by SEFAZ (cStat 100) | NF-e result object, nfeStatus = Autorizada |
invoice.rejected | NF-e rejected by SEFAZ | NF-e result object, nfeStatus = Negada, reason in nfeMotivoStatus |
invoice.canceled | NF-e cancellation registered by SEFAZ (cStat 135) | Event envelope, data carries invoice_id / chave / protocolo |
invoice.cce.registered | Correction letter (CC-e) registered on an NF-e | Event envelope, data carries invoice_id / chave / n_seq. Reserved: CC-e registration is synchronous today and this event is not delivered yet |
invoice.verify.completed | NF-e verification reached its final verdict (VALIDATED / REJECTED / VALIDATION_ERROR) | Event envelope with the verdict in data |
cte.authorized | CT-e authorized (cStat 100) | CT-e result object, cteStatus = Autorizada |
cte.rejected | CT-e rejected by SEFAZ (cStat other than 100) | CT-e result object, cteStatus = Negada, cteMotivoStatus = cStat - reason |
cte.canceled | CT-e cancellation registered (cStat 135) | CT-e result object, cteStatus = Cancelada |
cte.event.registered | Any other CT-e event registered (correction letter, delivery receipt, delivery failure, service disagreement and their cancellations) | Event envelope, data carries event_code / n_seq / protocolo |
dce.authorized | DC-e authorized (cStat 100) | DC-e result object, dceStatus = Autorizada |
dce.rejected | DC-e rejected by SEFAZ, or the issuance task failed terminally (including failures before the document was numbered) | DC-e result object, dceStatus = Negada |
dce.canceled | DC-e cancellation registered (cStat 135 / 136 / 155) | DC-e result object, dceStatus = Cancelada |
dce.cancel_rejected | SEFAZ rejected the DC-e cancellation, or the cancellation task failed terminally | DC-e result object, dceStatus = CancelamentoNegado, the document stays authorized |
Two payload shapes are used:
- Document result object: a bare JSON object whose first field is
tipo(NF-e/CT-e/DC-e), with a fixed field order. Used by the authorized, rejected and cancelled results of each document type. It has noevent_idfield; deduplicate on theX-Tffiscal-Event-Idheader. - Event envelope:
{ "version", "event_id", "event_type", "occurred_at", "data" }. Used byinvoice.verify.completedand by the event registration notifications. Fields insidedataare only ever added, never renamed or removed; a breaking change bumpsversion.
Note: SEFAZ rejections are never HTTP errors of the issuance call. They surface as the query status
Negadaand as theinvoice.rejected/cte.rejected/dce.rejectedevent. A CT-e task that fails terminally on the platform side (Falha) sends no callback; use the CT-e query to detect it.
Request headers
| Header | Meaning |
|---|---|
X-Tffiscal-Event | Event code, for example invoice.authorized (webhook.verify for the test delivery sent when a URL is saved in the console) |
X-Tffiscal-Event-Id | Event id, the idempotency key; unchanged across retries and shared by all targets of the same event |
X-Tffiscal-Delivery-Id | Delivery identifier; do not deduplicate on it, use the event id |
X-Tffiscal-Timestamp | Unix seconds, regenerated on every attempt |
X-Tffiscal-Signature | hex( HMAC-SHA256( app_secret, timestamp + "." + body ) ), lowercase |
token | The verification token you sent to Register webhook, returned verbatim |
x-token | Same value as token; verify either one |
Verifying the signature
Compute HMAC-SHA256 over the string timestamp + "." + rawBody with your app_secret and compare it in constant time with the X-Tffiscal-Signature header. Use the raw received bytes: deserializing and re-serializing the payload first changes field order or whitespace and breaks the signature. Reject deliveries whose timestamp is too old (5 minutes is a reasonable tolerance) to prevent replay.
Verifying the signature is recommended. At minimum compare the token header with the value you registered.
Node.js:
const crypto = require('node:crypto');/*** Verifies a TF Fiscal webhook delivery.* @param {string} secret your app_secret* @param {string} timestamp value of the X-Tffiscal-Timestamp header* @param {Buffer|string} rawBody the raw, unparsed request body* @param {string} signature value of the X-Tffiscal-Signature header* @returns {boolean} true when the signature is authentic*/function verifyWebhook(secret, timestamp, rawBody, signature) {const expected = crypto.createHmac('sha256', secret).update(timestamp + '.' + rawBody, 'utf8').digest('hex');return (expected.length === signature.length &&crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(signature, 'utf8')));}
Java:
import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;public final class WebhookVerifier {/*** Verifies a TF Fiscal webhook delivery.** @param secret your app_secret* @param timestamp value of the X-Tffiscal-Timestamp header* @param rawBody the raw, unparsed request body* @param signature value of the X-Tffiscal-Signature header* @return true when the signature is authentic*/public static boolean verify(String secret, String timestamp, String rawBody, String signature) {try {Mac mac = Mac.getInstance("HmacSHA256");mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));byte[] digest = mac.doFinal((timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));StringBuilder hex = new StringBuilder(digest.length * 2);for (byte b : digest) {hex.append(String.format("%02x", b));}return MessageDigest.isEqual(hex.toString().getBytes(StandardCharsets.UTF_8),signature.getBytes(StandardCharsets.UTF_8));} catch (Exception e) {return false;}}}
NF-e payload
Events invoice.authorized and invoice.rejected. Registered through Register webhook; see NF-e for the issuance flow.
Authorized:
{"tipo": "NF-e","empresaId": "1934811222334455","nfeId": "NFe-000014553","nfeStatus": "Autorizada","nfeLinkDanfe": "https://api.v2.tffiscal.com/openapi/files/danfe/35241204893402000113650010000117691017244265?token=MXw3fDEwMXwxNzU4...","nfeLinkXml": "https://api.v2.tffiscal.com/openapi/files/xml/8801?token=MXw3fDEwMXwxNzU4...","nfeNumero": "11769","nfeSerie": "10","nfeChaveAcesso": "35241204893402000113650010000117691017244265","nfeDataEmissao": "2024-12-04T17:44:26Z","nfeDataAutorizacao": "2024-12-04T17:44:26Z","nfeNumeroProtocolo": "135240002599237"}
Denied:
{"tipo": "NF-e","empresaId": "1934811222334455","nfeId": "NFe-000014553","nfeStatus": "Negada","nfeMotivoStatus": "778 - Rejeicao: NCM inexistente","nfeNumero": "11769","nfeSerie": "10","nfeChaveAcesso": "35241204893402000113650010000117691017244265","nfeDataEmissao": "2024-12-04T17:44:26Z"}
| Field | Type | Description |
|---|---|---|
tipo | string | Always NF-e |
empresaId | string | Company identifier |
nfeId | string | The id sent at issuance |
nfeStatus | string | Autorizada / Negada |
nfeMotivoStatus | string | Denial reason: SEFAZ status code + description; empty when authorized |
nfeLinkDanfe | string | DANFE PDF download link (see File download; usable at callback time, the first download triggers rendering); empty when denied |
nfeLinkXml | string | Authorized XML download link; empty when denied |
nfeNumero | string | Invoice number |
nfeSerie | string | Series |
nfeChaveAcesso | string | 44-digit access key |
nfeDataEmissao | string | Issuance time, ISO-8601 UTC |
nfeDataAutorizacao | string | Authorization time; empty when denied |
nfeNumeroProtocolo | string | Authorization protocol number; empty when denied |
nfeDigestValue | string | Signature digest, not provided at present |
Fields without a value appear in the payload as empty values.
NF-e cancellation and correction letter
invoice.canceled uses the event envelope with data carrying invoice_id (the platform invoice identifier), chave (44-digit access key) and protocolo (cancellation event protocol). The cancellation itself is confirmed synchronously by Cancel NF-e and by the query status Cancelada.
invoice.cce.registered is reserved in the catalogue (data: invoice_id / chave / n_seq). The CC-e registration endpoint returns the protocol synchronously and no callback is delivered for it at present.
CT-e payload
Same registration and headers as NF-e; see CT-e for the issuance flow. The result object uses tipo = CT-e with a fixed field order.
| Event | Trigger | cteStatus |
|---|---|---|
cte.authorized | Authorization 100 | Autorizada |
cte.rejected | SEFAZ rejection (cStat other than 100) | Negada (cteMotivoStatus is cStat - reason); a terminal task failure (Falha) sends no callback, use the query API |
cte.canceled | Cancellation 135 | Cancelada |
cte.event.registered | Any other event registered | Event envelope, data carries event_code / n_seq / protocolo |
Authorized:
{"tipo": "CT-e","empresaId": "1934811222334455","cteId": "CTE-ORD-1","cteStatus": "Autorizada","cteMotivoStatus": null,"cteLinkDacte": "https://api.v2.tffiscal.com/openapi/files/dacte/3526...?token=...","cteLinkXml": "https://api.v2.tffiscal.com/openapi/files/xml/7?token=...","cteNumero": "1","cteSerie": "1","cteChaveAcesso": "3526...","cteDataEmissao": "2026-09-06T12:00:00Z","cteDataAutorizacao": "2026-09-06T12:00:03Z","cteNumeroProtocolo": "135260000000001","cteDigestValue": "..."}
| Field | Type | Description |
|---|---|---|
tipo | string | Always CT-e |
empresaId | string | Company identifier |
cteId | string | The id sent at issuance |
cteStatus | string | Autorizada / Negada / Cancelada |
cteMotivoStatus | string | null | cStat - reason when rejected; null otherwise |
cteLinkDacte | string | null | DACTE PDF download link (rendered on the first download); null when rejected |
cteLinkXml | string | null | Authorized XML download link; null when rejected |
cteNumero | string | Document number |
cteSerie | string | Series |
cteChaveAcesso | string | 44-digit access key |
cteDataEmissao | string | Issuance time, ISO-8601 UTC |
cteDataAutorizacao | string | null | Authorization time; null when rejected |
cteNumeroProtocolo | string | null | Authorization protocol number; null when rejected |
cteDigestValue | string | null | Signature digest of the authorized XML |
cteLinkDacte and cteLinkXml are usable as soon as the authorization callback arrives; the links need no signature headers and redirect with 302, see File download.
Event registered (cte.event.registered), for example a delivery receipt (110180):
{"version": "1.0","event_id": "987654321098765432","event_type": "cte.event.registered","occurred_at": "2026-09-06T13:00:00Z","data": {"event_code": "110180","n_seq": "1","protocolo": "135260000000099"}}
| Field | Type | Description |
|---|---|---|
event_code | string | SEFAZ event code, for example 110110 correction letter, 110180 delivery receipt |
n_seq | string | Event sequence number |
protocolo | string | Event protocol number |
DC-e payload
Same registration and headers as NF-e; see DC-e for the issuance flow. The result object uses tipo = DC-e with 14 fields in fixed order.
| Event | Trigger | dceStatus |
|---|---|---|
dce.authorized | Authorization 100 | Autorizada |
dce.rejected | SEFAZ rejection or terminal task failure (including failures before the document is numbered, which have no chave) | Negada (dceMotivoStatus is cStat - reason; a terminal failure without cStat carries the reason only) |
dce.canceled | Cancellation registered (135 / 136 / 155) | Cancelada (dceDataAutorizacao is the cancellation registration time, dceNumeroProtocolo the cancellation event protocol) |
dce.cancel_rejected | SEFAZ rejected the cancellation or the cancellation task failed terminally | CancelamentoNegado (the document stays authorized; carries the authorization protocol / digest / XML link) |
Authorized:
{"tipo": "DC-e","empresaId": "1934811222334455","dceId": "DCe-000012333","dceStatus": "Autorizada","dceMotivoStatus": null,"dceLinkDace": "https://api.v2.tffiscal.com/openapi/files/dace/35260940673061000134990010000000011100000012?token=...","dceLinkXml": "https://api.v2.tffiscal.com/openapi/files/xml/7?token=...","dceNumero": "1","dceSerie": "1","dceChaveAcesso": "41260940673061000134990010000000011101234567","dceDataEmissao": "2026-09-06T12:00:00Z","dceDataAutorizacao": "2026-09-06T12:00:03Z","dceNumeroProtocolo": "141260000000001","dceDigestValue": "tasG40Ic6Zh7PIBiLEEH7Hb940Y="}
Rejected:
{"tipo": "DC-e","empresaId": "1934811222334455","dceId": "DCe-000012333","dceStatus": "Negada","dceMotivoStatus": "225 - Rejeicao: Falha no schema XML","dceLinkDace": null,"dceLinkXml": null,"dceNumero": "1","dceSerie": "1","dceChaveAcesso": "4126...","dceDataEmissao": "2026-09-06T12:00:00Z","dceDataAutorizacao": null,"dceNumeroProtocolo": null,"dceDigestValue": null}
Failure before numbering (company not configured for DC-e, missing series, message mapping failure and other terminal failures where the document was never numbered and has no chave): dce.rejected is still delivered, the document fact fields are null, dceMotivoStatus carries the failure reason and dceDataEmissao the acceptance time. Correlate by dceId and never assume dceChaveAcesso is present:
{"tipo": "DC-e","empresaId": "1934811222334455","dceId": "DCe-000012333","dceStatus": "Negada","dceMotivoStatus": "Empresa não configurada para emissão de DC-e","dceLinkDace": null,"dceLinkXml": null,"dceNumero": null,"dceSerie": null,"dceChaveAcesso": null,"dceDataEmissao": "2026-09-06T12:00:00Z","dceDataAutorizacao": null,"dceNumeroProtocolo": null,"dceDigestValue": null}
Cancelled:
{"tipo": "DC-e","empresaId": "1934811222334455","dceId": "DCe-000012333","dceStatus": "Cancelada","dceMotivoStatus": null,"dceLinkDace": "https://api.v2.tffiscal.com/openapi/files/dace/35260940673061000134990010000000011100000012?token=...","dceLinkXml": "https://api.v2.tffiscal.com/openapi/files/xml/7?token=...","dceNumero": "1","dceSerie": "1","dceChaveAcesso": "4126...","dceDataEmissao": "2026-09-06T12:00:00Z","dceDataAutorizacao": "2026-09-06T15:00:00Z","dceNumeroProtocolo": "141260000000099","dceDigestValue": null}
Cancellation rejected:
{"tipo": "DC-e","empresaId": "1934811222334455","dceId": "DCe-000012333","dceStatus": "CancelamentoNegado","dceMotivoStatus": "594 - Rejeicao: O numero de sequencia do evento informado e maior que o permitido","dceLinkDace": null,"dceLinkXml": "https://api.v2.tffiscal.com/openapi/files/xml/7?token=...","dceNumero": "1","dceSerie": "1","dceChaveAcesso": "4126...","dceDataEmissao": "2026-09-06T12:00:00Z","dceDataAutorizacao": null,"dceNumeroProtocolo": "141260000000001","dceDigestValue": "tasG40Ic6Zh7PIBiLEEH7Hb940Y="}
| Field | Type | Description |
|---|---|---|
tipo | string | Always DC-e |
empresaId | string | Company identifier |
dceId | string | The id sent at issuance; the correlation key for every event, present even when the document was never numbered |
dceStatus | string | Autorizada / Negada / Cancelada / CancelamentoNegado |
dceMotivoStatus | string | null | cStat - reason for a SEFAZ rejection; reason only for a terminal failure without cStat; null on success |
dceLinkDace | string | null | DACE PDF download link, rendered on the first download; null when rejected and in CancelamentoNegado |
dceLinkXml | string | null | Authorized XML download link; null when rejected |
dceNumero | string | null | Document number; null when never numbered |
dceSerie | string | null | Series; null when never numbered |
dceChaveAcesso | string | null | 44-digit access key; null when never numbered |
dceDataEmissao | string | Issuance time, ISO-8601 UTC (acceptance time for a failure before numbering) |
dceDataAutorizacao | string | null | Authorization time; cancellation registration time in Cancelada; null when rejected |
dceNumeroProtocolo | string | null | Authorization protocol; cancellation event protocol in Cancelada; null when rejected |
dceDigestValue | string | null | Signature digest of the authorized XML; null when rejected and in Cancelada |
In the authorized / cancelled callbacks dceLinkDace is rendered lazily by chave (nothing is rendered at delivery time; the first download renders and archives it). Both links share the query API shape (/openapi/files/{kind}/{ref}?token=...) and their lifetime comes from the tenant-level setting (7 days by default), see File download.
Verification verdict payload
When Tier-2 SEFAZ verification settles, the platform POSTs invoice.verify.completed to your webhook URL. This is the only push channel for final verdicts; the chave lookup can serve as a polling fallback.
{"version": "1.0","event_id": "1950000000000001","event_type": "invoice.verify.completed","occurred_at": "2026-07-23T17:16:23Z","data": {"chaveAcesso": "35260764962869000108550990001366171195929648","validationStatus": "VALIDATED","status": "Autorizada","cStat": "100","xMotivo": "Autorizado o uso da NF-e","protocolo": { "numero": "135262955451772", "digestValue": "oAEE...HwY=" },"dataAutorizacao": "2026-07-23T14:30:09Z","eventos": [],"verifiedAt": "2026-07-23T17:16:23Z","reason": "present only for REJECTED / VALIDATION_ERROR (stable English text)"}}
Envelope:
| Field | Type | Presence | Description |
|---|---|---|---|
version | string | always | Payload schema version, currently 1.0 |
event_id | string | always | Event id, idempotency key, identical across retries |
event_type | string | always | invoice.verify.completed |
occurred_at | string | always | Event time, ISO-8601 UTC |
data | object | always | Verdict body, below |
data:
| Field | Type | Presence | Description |
|---|---|---|---|
chaveAcesso | string | always | 44-digit access key of the verified invoice, the join key back to your submission |
validationStatus | string | always | Final verdict: VALIDATED / REJECTED / VALIDATION_ERROR, see NF-e verification |
status | string | always | SEFAZ fiscal status: Autorizada / Cancelada / Denegada / Inutilizada / NaoEncontrada / Desconhecida |
cStat | string | null | nullable | Raw SEFAZ return code (for example 100 authorized, 101 cancelled); null when SEFAZ was not reached |
xMotivo | string | null | nullable | Raw SEFAZ return message (Portuguese, verbatim) |
protocolo | object | null | nullable | Protocol object (numero, digestValue) from the official record |
dataAutorizacao | string | null | nullable | SEFAZ authorization time, ISO-8601 UTC |
eventos[] | array | always (may be empty) | Fiscal events registered against the invoice (cancellation, correction letters); empty when none |
verifiedAt | string | always | When Tier-2 verification completed, ISO-8601 UTC |
reason | string | only on failure | Present only for REJECTED / VALIDATION_ERROR; stable English text explaining the verdict |
validationStatus | Terminal | Action |
|---|---|---|
VALIDATED | Yes | Safe to proceed (release goods, settle) |
REJECTED | Yes | Do not proceed; reason explains the SEFAZ verdict (cancelled / denied / voided / not found / protocol mismatch) |
VALIDATION_ERROR | No | Platform-side verification failure, not an invoice judgment; resubmit later through XML verification with header forceRevalidate: true |
Webhook payloads carry only language-independent enum values; there are no *Description fields. Presentation text is up to the receiver.
Retries and circuit breaker
- Any 2xx status acknowledges the delivery. Any other status, a network error or no response within 10 seconds counts as a failure.
- Failed deliveries are retried with backoff after the initial attempt:
1 min, 5 min, 30 min, 2 h, 6 h
- Five retries (six attempts in total, spanning about 8.6 hours). After that the delivery is parked in a dead-letter queue; the platform can re-push it manually on request. Every attempt is logged with its HTTP status, duration and response summary, which is the evidence used when investigating a missing callback.
- Circuit breaker: consecutive failed deliveries (10 by default) automatically disable the webhook. While it is disabled no new event is queued for it. Calling Register webhook again (or re-verifying and saving the URL in the console) restores delivery and resets the failure counter; ask the platform to replay events raised while the webhook was disabled.
- The webhook URL and
tokenare re-read before every attempt, so a re-registration takes effect on the next retry.
Receiver requirements
- Return 2xx within 10 seconds. Best practice: persist the raw delivery, return 2xx immediately and process asynchronously.
- Verify the origin: recompute the HMAC over the raw received bytes, and compare the
tokenheader with the registered value. - Deduplicate on the event id (
X-Tffiscal-Event-Id, orevent_idin enveloped payloads). - Public URL: an absolute
http/httpsURL reachable from the internet, at most 500 characters. Loopback, private and link-local addresses are rejected at registration. - Answer the test delivery: saving the URL in the console sends an event with
X-Tffiscal-Event: webhook.verify; return 2xx without any business processing. - Gate business actions on the result fields (
nfeStatus,cteStatus,dceStatus,validationStatus), never on the synchronous API response alone. - Never assume optional fields are present:
dceChaveAcessocan benull,reasononly appears on failure, and empty NF-e fields arrive as empty values.
Troubleshooting
Callbacks not arriving?
Make sure the URL is a publicly reachable https/http address that returns 2xx within 10 seconds. The platform retries with backoff and disables the webhook after consecutive failures; calling the registration endpoint again restores delivery.
Signature keeps failing?
The most common cause is deserializing the payload and re-serializing it before computing the HMAC, which changes field order or whitespace. Always hash the raw received bytes, and concatenate the X-Tffiscal-Timestamp value exactly as received.
Same event received twice?
This is expected under at-least-once delivery. Deduplicate on the event id; the delivery id differs per target and must not be used for that.
