TF Fiscal
Documentation

NF-e verification

Two-tier verification of third-party NF-e documents: XML verification, chave lookup, the final verdict delivered by webhook, idempotency and the error model.

Overview

The platform performs two-tier verification of third-party NF-e documents (model 55):

TierWhat it doesWhen
Tier 1 (synchronous)Local validation of the XML: syntax, NF-e 4.00 XSD layout, digital signature and certificate ownership, 44-digit access key (chave) structure and consistency with document fields, authorization protocol integrityReturned immediately in the API response
Tier 2 (asynchronous)Real-time authenticity check against SEFAZ (the state tax authority): authorization status, cancellation events, protocol number and digest compared with official recordsRuns after Tier 1 passes; the verdict is delivered via webhook (see Receiving the final verdict), and the chave lookup returns the current state as a polling fallback

A standalone chave lookup returns invoice data by access key. For a chave you submitted through XML verification it also returns the current validation block, so it doubles as the polling fallback when a webhook delivery is missed.

Both endpoints require the three signature headers, see Authentication. Responses are bare objects (no platform envelope); request-level errors are a bare {code, message} object.

Verification lifecycle

text
submit XML ──► Tier 1
├─ blocking error ──────────────► REJECTED (terminal)
└─ pass ──► PENDING_SEFAZ ──► VALIDATING (Tier 2)
├──► VALIDATED (terminal)
├──► REJECTED (terminal, with reason)
└──► VALIDATION_ERROR (retryable, with reason)
validationStatusMeaning
VALIDATEDSEFAZ confirms the invoice is authentic and effective (authorized, no cancellation, protocol matches)
REJECTEDVerification failed: a Tier-1 blocking error, or SEFAZ reports the invoice is cancelled / denied / voided / not found / protocol mismatch. reason is provided
VALIDATION_ERRORVerification could not complete (SEFAZ throttling or query failure after retries). Not a judgment on the invoice itself; resubmit later with forceRevalidate: true

Onboarding

  1. Application credential: apply for an application and receive an app_secret. It is displayed only once; store it securely. If leaked, request rotation.
  2. API subscription: the platform enables your application for POST /openapi/v3/consultas/nf-e/xml and GET /openapi/v3/consultas/nf-e/{chave}.
  3. Webhook endpoint: register your callback URL through Register webhook and subscribe to the event invoice.verify.completed (the only push channel for Tier-2 verdicts). When the URL is saved, the platform immediately sends a test delivery with event_type=webhook.verify; your endpoint must return 2xx for the save to succeed (returning 200 without processing is fine for the test event).

Endpoints

EndpointPurpose
POST /openapi/v3/consultas/nf-e/xmlSubmit the raw NF-e XML; returns the Tier-1 verdict and the parsed invoice, starts Tier 2
GET /openapi/v3/consultas/nf-e/{chave}Lookup by access key; returns invoice data, plus the current validation block for chaves you submitted

Receiving the final verdict

When Tier-2 SEFAZ verification settles, the platform POSTs to your registered webhook URL. This is the only push channel for final verdicts; if a delivery is missed, the chave lookup returns the current validationStatus and serves as the polling fallback.

Request headers

HeaderDescription
X-Tffiscal-Eventinvoice.verify.completed
X-Tffiscal-Event-IdEvent id, the idempotency key: unchanged across retries; deduplicate on this
X-Tffiscal-Delivery-IdDelivery id, one per target URL; unchanged across retries to that target (deduplicate on the event id, not on this)
X-Tffiscal-TimestampUnix seconds, regenerated on every attempt
X-Tffiscal-Signaturehex( HMAC-SHA256( secret, timestamp + "." + body ) ), lowercase; secret = your app_secret for an application-level webhook URL (a tenant-level fallback URL is signed with the secret configured for it)

Payload

json
{
"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": "oAEEuC3tGmb2W7ZJxWkNVWuBHwY=" },
"dataAutorizacao": "2026-07-23T14:30:09Z",
"eventos": [],
"verifiedAt": "2026-07-23T17:16:23Z"
}
}

Envelope fields:

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

data fields:

FieldTypePresenceDescription
chaveAcessostringalways44-digit access key of the verified invoice; join key back to your submission
validationStatusstringalwaysFinal verdict: VALIDATED / REJECTED / VALIDATION_ERROR (see Verification lifecycle)
statusstring | nullnullableSEFAZ fiscal status: Autorizada / Cancelada / Denegada / Inutilizada / NaoEncontrada / Desconhecida; null for VALIDATION_ERROR (no SEFAZ verdict was obtained)
cStatstring | nullnullableRaw SEFAZ return code (e.g. 100 = authorized, 101 = cancelled); null when SEFAZ was not reached
xMotivostring | nullnullableRaw SEFAZ return message (Portuguese, verbatim)
protocoloobject | nullnullableProtocol object (numero, digestValue), as on the XML verification response; 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

Note: webhook payloads carry only language-independent enum values; there are no *Description fields here. Presentation text is up to the receiver.

Receiver requirements

  1. Verify the signature: recompute HMAC-SHA256(secret, timestamp + "." + rawBody) and compare with the header. Use the raw received bytes; do not deserialize and re-serialize first (field reordering breaks the signature).
  2. Return 2xx within 10 seconds. Anything else, including timeouts, counts as a failed delivery.
  3. Failed deliveries are retried with backoff 1m / 5m / 30m / 2h / 6h (5 attempts); manual re-push is available on request.
  4. Deduplicate on event_id (retries and multi-target fan-out share the same event_id).
  5. One chave can produce several events over time: a re-verification with forceRevalidate: true, a resubmission after the 24-hour window, or a background re-check triggered by the chave lookup each end in a new event with its own event_id. Keep the verdict from the event with the latest occurred_at.

Verdict handling

validationStatusTerminal?Action
VALIDATEDYesSafe to proceed (release goods, settle, etc.)
REJECTEDYesDo not proceed; reason explains the SEFAZ verdict (cancelled / denied / voided / not found / protocol mismatch)
VALIDATION_ERRORYes, retryablePlatform-side verification failure, not an invoice judgment. Like any verdict it is reused for 24 hours, so resubmit later with forceRevalidate: true

Idempotency and re-verification

  • XML verification is idempotent on the chave (the access key inside the submitted XML):
    • While Tier 2 is in progress (PENDING_SEFAZ / VALIDATING), resubmitting returns the current progress and does not start another check. forceRevalidate: true does not restart or speed up a check that is already running.
    • A terminal verdict (VALIDATED / REJECTED / VALIDATION_ERROR) is reused for 24 hours from the moment it was reached: resubmitting within that window returns it without contacting SEFAZ again. After 24 hours, a plain resubmission runs the full verification (Tier 1 + Tier 2) again.
    • A reused response reflects the originally submitted XML (parsed invoice data, Tier-1 checks, errors and warnings) together with the current validationStatus. If you resubmit a corrected XML for the same chave within the window, send forceRevalidate: true; otherwise you get the earlier result back.
  • Forced re-verification: send the header forceRevalidate: true on the XML verification endpoint (not part of the signature). It skips reuse and runs Tier 1 and Tier 2 again; the new verdict arrives as a new webhook event.
  • Chave lookup: it cannot force a re-check. It schedules one by itself only when the latest SEFAZ answer is older than 24 hours (see chave lookup).
  • A Tier-1 blocking REJECTED has no Tier-2 record; to re-verify, resubmit the XML (with forceRevalidate: true inside the 24-hour window).
  • Verifications belong to your integrator account: reuse, chave lookups and webhooks only see your own submissions. Another integrator verifying the same invoice does not affect yours; each account is verified independently and gets its own verdict.

Error model

Two error shapes

Business and request-level errors (HTTP 400) are returned as a bare object:

json
{ "code": 10015004, "message": "Invoice not found: 35260764962869000108550990001366171195929648" }
FieldTypeDescription
codeintegerPlatform error code
messagestringHuman-readable explanation, localized (see Response localization)

Authentication-layer errors (HTTP 401 / 403 / 429) are produced by the platform gateway before your request reaches the API, and use the platform envelope:

json
{ "success": false, "errorType": 1, "code": 10009003, "message": "Signature error" }
FieldTypeDescription
successbooleanfalse for every error
errorTypeintegerError class: 1 API error, 2 SEFAZ rejection, 3 system fault, 4 field validation failure
codeintegerPlatform error code
messagestringHuman-readable explanation, localized
dataobject | nullNot populated on errors

Your error handler must accept both shapes. A robust rule: parse the body as JSON, read code from either shape, and treat success=false or HTTP >= 400 as failure.

Authentication and authorization errors

HTTPcodeMeaningAction
40110009000Missing signature headers (token / sign / timestamp)Fix the client: send all three headers on every request
40110009001Timestamp invalid or clock skew beyond ±300 sSync your clock (NTP); regenerate timestamp per request, never reuse
40110009002Invalid tokenCheck the app_secret; if rotated, update your configuration
40110009003Signature mismatchRe-derive the signature; see the checklist in Troubleshooting
40310009004Application disabledContact the platform
40310009015Application not effective (pending approval or rejected)Wait for approval / contact the platform
40310009014Integrator account disabledContact the platform
40310009005API not subscribedRequest subscription for the endpoint being called
42910009006Rate limit exceededBack off and retry; smooth your call rate. Limits are enforced per application and per endpoint group
41310009027Request body exceeds the gateway size limitXML verification accepts at most 1 MB; make sure you send a single NF-e

Retry guidance: 401 and 403 are configuration errors; retrying without a fix is pointless and may trip rate limits. 429 is retryable with exponential backoff (start at 1 s, double up to 30 s, add jitter).

Request-level and validation errors

Documented on each endpoint page:

  • XML verification: request-level errors 10015000 to 10015003 (HTTP 400, bare shape, the request never enters validation) and the Tier-1 validation catalog XML_MALFORMED to XML_VERSION_UNSUPPORTED (HTTP 200, inside validation.errors[]).
  • Chave lookup: 10015104 (malformed chave) and 10015004 (invoice not found), HTTP 400, bare shape.
  • Tier-2 outcomes are not HTTP errors; see Verdict handling.

HTTP status quick reference

HTTPScenarioBody shape
200Request accepted, including failed validation (check the validation block)Bare data
400Request itself invalid (empty / oversized / DTD / encoding / malformed chave / not found)Bare {code, message}
401Authentication failed (token / sign / timestamp)Platform envelope
413Body exceeds the gateway size limitPlatform envelope
403Application disabled / not effective / integrator disabled / not subscribedPlatform envelope
429Rate limit exceededPlatform envelope
5xxPlatform-side faultRetry with backoff; if persistent, contact the platform with the failing timestamp and path

Response localization

message and *Description fields follow the request language: the Language header (en / pt / es / zh) takes precedence, then Accept-Language (supports pt-BR and q-values). Without a language header, responses default to Portuguese (pt).

For programmatic decisions always use the language-independent enum fields (validationStatus, status, errors[].code); never match on description text.

Integration checklist

  1. Obtain the app_secret; cross-check your signing implementation against the sign-helper output (once for POST with an XML body, once for GET with an empty body).
  2. POST /openapi/v3/consultas/nf-e/xml with a genuine authorized nfeProc: HTTP 200, all five validation checks true, validationStatus=PENDING_SEFAZ.
  3. Receive the invoice.verify.completed webhook: signature verifies, deduplicated by event_id, verdict VALIDATED.
  4. Chave lookup: a previously submitted chave returns 200 with invoice data and the validation block (same validationStatus as the webhook verdict); a nonexistent chave returns 400 + 10015004; a chave with a wrong check digit returns 400 + 10015104.
  5. Negative cases: submit a tampered XML (SIGNATURE_INVALID); submit an XML without protNFe (warning only, still accepted).
  6. Idempotency: resubmit the same chave and get the reused verdict; submit with forceRevalidate: true and get a re-verification with a new verdict via webhook.
  7. Failure paths: call with a wrong sign (401, code 10009003); call an unsubscribed endpoint (403, code 10009005).

Troubleshooting

Signature never matches (401, code 10009003)?

Check, in order of frequency:

  1. CR/LF not stripped from the body before concatenation;
  2. GET request concatenated "null" instead of the empty string for the body;
  3. path missing the /openapi prefix, or including the query string;
  4. sign sent in uppercase (must be lowercase hex);
  5. the timestamp value used in concatenation differs from the header (regenerated between the two);
  6. body bytes re-encoded (must hash the exact bytes sent on the wire, UTF-8).

HTTP 200 but the invoice is fake?

Tier 1 only judges whether the XML is internally consistent. Authenticity is decided by Tier 2 against SEFAZ and delivered via webhook; gate your business action (release, settlement) on the webhook VALIDATED, never on the synchronous response alone.

Is the status in the synchronous response the SEFAZ status?

No. In the first response status is read from the protocol inside the XML you sent; SEFAZ has not been consulted yet. The status confirmed by SEFAZ arrives with the Tier-2 verdict: the webhook status, or the chave lookup once Tier 2 has an answer.

REJECTED with status NaoEncontrada: is the invoice fake?

Not necessarily. SEFAZ answered "not found" (cStat 217). The platform does not conclude on the first 217: it queries SEFAZ again several times over a few minutes, because a just-authorized invoice can take a while to become queryable, and only a persistent 217 becomes REJECTED with NaoEncontrada. Common causes:

  1. the invoice was authorized moments ago and SEFAZ has not finished propagating it; re-verify later with forceRevalidate: true;
  2. the invoice was issued in the SEFAZ homologation environment (ambienteEmissao=Homologacao): Tier 2 queries the production environment, where test invoices do not exist;
  3. the invoice was never authorized under that chave.

This is different from the chave lookup's 10015004, which means none of the platform's data sources knows the chave.

Webhook 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.

Is VALIDATION_ERROR a problem with the invoice?

No. It means the platform's verification channel failed (e.g. SEFAZ throttling); the invoice itself was not judged. Resubmit later with forceRevalidate: true.

Clock skew (401, code 10009001)?

Your server clock differs from ours by more than 300 seconds. Use NTP. Never cache or reuse timestamps across requests.