Skip to Content

Webhooks

The platform pushes two kinds of results to your servers so you do not need to poll:

ChannelHow it is configuredPayload
Issuance result callbackPOST /openapi/v1/webhooks (see Issuance API); subscribes automatically to the authorized and denied resultsBare NF-e result object (nfeStatus = Autorizada / Negada)
Verification verdict eventApplication-level webhook URL in the console, subscribed to invoice.verify.completedVersioned 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

HeaderMeaning
X-Tffiscal-EventEvent type (e.g. invoice.verify.completed, webhook.verify)
X-Tffiscal-Event-IdEvent id — idempotency key, unchanged across retries; deduplicate on this
X-Tffiscal-Delivery-IdDelivery identifier; do not deduplicate on it — use the event id
X-Tffiscal-TimestampUnix seconds, regenerated on every attempt
X-Tffiscal-Signaturehex( HMAC-SHA256( secret, timestamp + "." + body ) ), lowercase; secret = your app_secret
tokenIssuance 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 / https absolute 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" }
FieldTypeDescription
tipostringAlways NF-e
empresaIdstringCompany identifier
nfeIdstringThe id sent at issuance
nfeStatusstringAutorizada / Negada
nfeMotivoStatusstringDenial reason: SEFAZ status code + description; empty when authorized
nfeLinkDanfestringDANFE download link. Empty at callback time: the DANFE is rendered on demand — fetch it through the query endpoint
nfeLinkXmlstringAuthorized XML download link (10-minute token); empty when denied
nfeNumerostringInvoice number
nfeSeriestringSeries
nfeChaveAcessostring44-digit access key
nfeDataEmissaostringIssuance time, ISO-8601 UTC
nfeDataAutorizacaostringAuthorization time; empty when denied
nfeNumeroProtocolostringAuthorization protocol number; empty when denied
nfeDigestValuestringSignature 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:

FieldTypePresenceDescription
versionstringalwaysPayload schema version, currently 1.0
event_idstringalwaysEvent id — idempotency key, identical across retries
event_typestringalwaysinvoice.verify.completed
occurred_atstringalwaysEvent time, ISO-8601 UTC
dataobjectalwaysVerdict body, below

data:

FieldTypePresenceDescription
chaveAcessostringalways44-digit access key of the verified invoice — join key back to your submission
validationStatusstringalwaysFinal verdict: VALIDATED / REJECTED / VALIDATION_ERROR (see Verification lifecycle)
statusstringalwaysSEFAZ fiscal status: Autorizada / Cancelada / Denegada / Inutilizada / NaoEncontrada / Desconhecida
cStatstring | nullnullableRaw SEFAZ return code (e.g. 100 = authorized, 101 = cancelled); null when SEFAZ was not reached
xMotivostring | nullnullableRaw SEFAZ return message (Portuguese, verbatim)
protocoloProtocol | nullnullableProtocol object (numero, digestValue) from the official record
dataAutorizacaostring | nullnullableSEFAZ authorization time, ISO-8601 UTC
eventos[]arrayalways (may be empty)Fiscal events registered against the invoice (cancellation, correction letters); empty when none
verifiedAtstringalwaysWhen Tier-2 verification completed, ISO-8601 UTC
reasonstringonly on failurePresent 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

  1. Verify the signature against the raw received bytes (and, for issuance callbacks, the token header).
  2. Return 2xx within 10 seconds; process asynchronously.
  3. Deduplicate on the event id.
  4. 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.

Last updated on