Webhooks
The platform pushes two kinds of results to your servers so you do not need to poll:
| Channel | How it is configured | Payload |
|---|---|---|
| Issuance result callback | POST /openapi/v1/webhooks (see Issuance API); subscribes automatically to the authorized and denied results | Bare NF-e result object (nfeStatus = Autorizada / Negada) |
| Verification verdict event | Application-level webhook URL in the console, subscribed to invoice.verify.completed | Versioned event envelope with the Tier-2 verdict in data |
Delivery is at-least-once for both channels: design your receiver to be idempotent.
Delivery contract
Each delivery is an HTTP POST with a JSON body to your URL.
Request headers
| Header | Meaning |
|---|---|
X-Tffiscal-Event | Event type (e.g. invoice.verify.completed, webhook.verify) |
X-Tffiscal-Event-Id | Event id — idempotency key, unchanged across retries; deduplicate on this |
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( secret, timestamp + "." + body ) ), lowercase; secret = your app_secret |
token | Issuance result callback only: the verification token you registered, sent back verbatim |
Verifying the signature
Compute HMAC-SHA256 over the string timestamp + "." + rawBody with your app_secret
and compare it (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.
For the issuance result callback, verifying the signature is optional; 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;
}
}
}Response contract
- Any 2xx status acknowledges the delivery. Anything else — including timeouts — counts as a failure and schedules a retry.
- Respond within 10 seconds. Best practice: persist the event, return 2xx immediately, and process asynchronously.
Retries and dead-lettering
Failed deliveries are retried with backoff after the initial attempt:
1 min → 5 min → 30 min → 2 h → 6 h- Five retries, then the delivery is parked in a dead-letter queue; manual re-push is available on request.
- Deduplicate on
X-Tffiscal-Event-Id/event_id: retries and multi-target fan-out share the same event id, and a retry after a timeout can reach you even though the original attempt was actually processed. - For the issuance result callback, consecutive failures trip a circuit breaker that stops delivery; calling the webhook registration endpoint again restores it.
URL requirements
http/httpsabsolute URLs that are publicly reachable.- For the verification channel, saving the URL in the console triggers an immediate test
delivery with
event_type=webhook.verify; the save succeeds only if your endpoint returns 2xx. Returning 200 without processing is fine for the test event.
Issuance result callback
Registered with POST /openapi/v1/webhooks. The platform POSTs results to the registered
uri with the token header (the value you registered) plus the platform signature
header X-Tffiscal-Signature (HMAC-SHA256 keyed by your app_secret), which you may
verify optionally.
Authorized:
{
"tipo": "NF-e",
"empresaId": "1934811222334455",
"nfeId": "NFe-000014553",
"nfeStatus": "Autorizada",
"nfeLinkXml": "https://api.v2.tffiscal.com/open/files/8801?token=eyJh...",
"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 download link. Empty at callback time: the DANFE is rendered on demand — fetch it through the query endpoint |
nfeLinkXml | string | Authorized XML download link (10-minute token); 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.
Verification verdict event
When Tier-2 SEFAZ verification settles, the platform POSTs invoice.verify.completed to
your application-level 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 — join key back to your submission |
validationStatus | string | always | Final verdict: VALIDATED / REJECTED / VALIDATION_ERROR (see Verification lifecycle) |
status | string | always | SEFAZ fiscal status: Autorizada / Cancelada / Denegada / Inutilizada / NaoEncontrada / Desconhecida |
cStat | string | null | nullable | Raw SEFAZ return code (e.g. 100 = authorized, 101 = cancelled); null when SEFAZ was not reached |
xMotivo | string | null | nullable | Raw SEFAZ return message (Portuguese, verbatim) |
protocolo | Protocol | 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 |
Webhook payloads carry only language-independent enum values — there are no
*Description fields here. Presentation text is up to the receiver. Versioning policy:
fields inside data are only ever added, never renamed or removed; a breaking change
bumps version.
Receiver checklist
- Verify the signature against the raw received bytes (and, for issuance callbacks,
the
tokenheader). - Return 2xx within 10 seconds; process asynchronously.
- Deduplicate on the event id.
- Gate business actions on the verdict fields (
nfeStatus,validationStatus), never on the synchronous API response alone.
Troubleshooting
Callbacks not arriving? Make sure the URL is a publicly reachable https/http address that returns 2xx. The platform retries with backoff and, for issuance callbacks, trips a circuit breaker after consecutive failures — calling the registration endpoint again restores delivery.
Webhook signature keeps failing? The most common cause is deserializing the payload and re-serializing it before computing the HMAC — field order or whitespace changes. Always hash the raw received bytes.