TF Fiscal
Documentación

Webhooks

Catálogo de eventos, cabeceras de entrega, firma HMAC-SHA256, payloads por tipo de documento, reintentos y circuit breaker de los webhooks de TF Fiscal.

Modelo de entrega

La plataforma envía los resultados de los documentos y los veredictos de verificación a su servidor, sin necesidad de polling.

  • Una URL de callback por aplicación, registrada en Registrar webhook. Llamar de nuevo al endpoint sobrescribe la URL y el token. Los resultados de NF-e, CT-e y DC-e y el veredicto de la verificación de NF-e se entregan todos en esa URL.
  • Entrega al menos una vez (at-least-once). Un reintento tras un timeout puede llegar aunque el intento original ya se haya procesado, y el mismo evento puede distribuirse a varios destinos. Deduplique por el id del evento (cabecera X-Tffiscal-Event-Id y, en los payloads con sobre, event_id): es idéntico en todos los intentos del mismo evento.
  • Cada entrega es un HTTP POST con cuerpo JSON (Content-Type: application/json; charset=utf-8) a la URL registrada.
  • Los documentos de prueba y de producción siguen el mismo contrato, la misma firma y el mismo calendario de reintentos, vea Entornos.

Catálogo de eventos

EventoSe dispara cuandoPayload
invoice.authorizedNF-e autorizada por SEFAZ (cStat 100)Objeto de resultado NF-e, nfeStatus = Autorizada
invoice.rejectedNF-e rechazada por SEFAZObjeto de resultado NF-e, nfeStatus = Negada, motivo en nfeMotivoStatus
invoice.canceledCancelación de la NF-e registrada en SEFAZ (cStat 135)Sobre de evento, data trae invoice_id / chave / protocolo
invoice.cce.registeredCarta de corrección (CC-e) registrada en una NF-eSobre de evento, data trae invoice_id / chave / n_seq. Reservado: el registro de la CC-e es síncrono hoy y este evento todavía no se entrega
invoice.verify.completedLa verificación de NF-e llegó al veredicto final (VALIDATED / REJECTED / VALIDATION_ERROR)Sobre de evento con el veredicto en data
cte.authorizedCT-e autorizado (cStat 100)Objeto de resultado CT-e, cteStatus = Autorizada
cte.rejectedCT-e rechazado por SEFAZ (cStat distinto de 100)Objeto de resultado CT-e, cteStatus = Negada, cteMotivoStatus = cStat - motivo
cte.canceledCancelación del CT-e registrada (cStat 135)Objeto de resultado CT-e, cteStatus = Cancelada
cte.event.registeredCualquier otro evento de CT-e registrado (carta de corrección, comprobante de entrega, entrega fallida, desacuerdo de servicio y sus cancelaciones)Sobre de evento, data trae event_code / n_seq / protocolo
dce.authorizedDC-e autorizada (cStat 100)Objeto de resultado DC-e, dceStatus = Autorizada
dce.rejectedDC-e rechazada por SEFAZ, o la tarea de emisión falló de forma terminal (incluso antes de numerar el documento)Objeto de resultado DC-e, dceStatus = Negada
dce.canceledCancelación de la DC-e registrada (cStat 135 / 136 / 155)Objeto de resultado DC-e, dceStatus = Cancelada
dce.cancel_rejectedSEFAZ rechazó la cancelación de la DC-e, o la tarea de cancelación falló de forma terminalObjeto de resultado DC-e, dceStatus = CancelamentoNegado, el documento sigue autorizado

Se usan dos formas de payload:

  • Objeto de resultado del documento: objeto JSON plano cuyo primer campo es tipo (NF-e / CT-e / DC-e), con orden fijo de campos. Lo usan los resultados de autorización, rechazo y cancelación de cada tipo de documento. No tiene campo event_id; deduplique por la cabecera X-Tffiscal-Event-Id.
  • Sobre de evento: { "version", "event_id", "event_type", "occurred_at", "data" }. Lo usan invoice.verify.completed y las notificaciones de registro de eventos. Los campos dentro de data solo se añaden, nunca se renombran ni se eliminan; un cambio incompatible incrementa version.

Nota: los rechazos de SEFAZ nunca son errores HTTP de la llamada de emisión. Aparecen como estado Negada en la consulta y como evento invoice.rejected / cte.rejected / dce.rejected. Una tarea de CT-e que falla de forma terminal del lado de la plataforma (Falha) no genera callback; use la consulta de CT-e para detectarla.

Cabeceras de la petición

CabeceraSignificado
X-Tffiscal-EventCódigo del evento, por ejemplo invoice.authorized (webhook.verify en la entrega de prueba enviada al guardar una URL en la consola)
X-Tffiscal-Event-IdId del evento, la clave de idempotencia; no cambia entre reintentos y es compartido por todos los destinos del mismo evento
X-Tffiscal-Delivery-IdIdentificador de la entrega; no deduplique por él, use el id del evento
X-Tffiscal-TimestampSegundos Unix, regenerado en cada intento
X-Tffiscal-Signaturehex( HMAC-SHA256( app_secret, timestamp + "." + body ) ), en minúsculas
tokenEl token de verificación que envió en Registrar webhook, devuelto sin cambios
x-tokenMismo valor que token; verifique cualquiera de los dos

Verificar la firma

Calcule HMAC-SHA256 sobre la cadena timestamp + "." + rawBody con su app_secret y compárelo en tiempo constante con la cabecera X-Tffiscal-Signature. Use los bytes crudos recibidos: deserializar y volver a serializar el payload cambia el orden de los campos o los espacios y rompe la firma. Rechace entregas con timestamp demasiado antiguo (5 minutos es una tolerancia razonable) para evitar replay.

Se recomienda verificar la firma. Como mínimo, compare la cabecera token con el valor registrado.

Node.js:

javascript
const crypto = require('node:crypto');
/**
* Verifica una entrega de webhook de TF Fiscal.
* @param {string} secret su app_secret
* @param {string} timestamp valor de la cabecera X-Tffiscal-Timestamp
* @param {Buffer|string} rawBody cuerpo crudo de la petición, sin parsear
* @param {string} signature valor de la cabecera X-Tffiscal-Signature
* @returns {boolean} true cuando la firma es auténtica
*/
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:

java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class WebhookVerifier {
/**
* Verifica una entrega de webhook de TF Fiscal.
*
* @param secret su app_secret
* @param timestamp valor de la cabecera X-Tffiscal-Timestamp
* @param rawBody cuerpo crudo de la petición, sin parsear
* @param signature valor de la cabecera X-Tffiscal-Signature
* @return true cuando la firma es auténtica
*/
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;
}
}
}

Payload de NF-e

Eventos invoice.authorized e invoice.rejected. Se registran en Registrar webhook; vea NF-e para el flujo de emisión.

Autorizada:

json
{
"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"
}

Denegada:

json
{
"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"
}
CampoTipoDescripción
tipostringSiempre NF-e
empresaIdstringIdentificador de la empresa
nfeIdstringEl id enviado en la emisión
nfeStatusstringAutorizada / Negada
nfeMotivoStatusstringMotivo de la denegación: código de estado de SEFAZ + descripción; vacío cuando está autorizada
nfeLinkDanfestringEnlace de descarga del PDF del DANFE (vea Descarga de archivos; utilizable en el momento del callback, la primera descarga dispara el renderizado); vacío cuando está denegada
nfeLinkXmlstringEnlace de descarga del XML autorizado; vacío cuando está denegada
nfeNumerostringNúmero de la factura
nfeSeriestringSerie
nfeChaveAcessostringClave de acceso de 44 dígitos
nfeDataEmissaostringFecha de emisión, ISO-8601 UTC
nfeDataAutorizacaostringFecha de autorización; vacío cuando está denegada
nfeNumeroProtocolostringNúmero de protocolo de autorización; vacío cuando está denegada
nfeDigestValuestringDigest de la firma, no se proporciona por ahora

Los campos sin valor aparecen en el payload como valores vacíos.

Cancelación y carta de corrección de NF-e

invoice.canceled usa el sobre de evento con data trayendo invoice_id (identificador de la factura en la plataforma), chave (clave de acceso de 44 dígitos) y protocolo (protocolo del evento de cancelación). La cancelación en sí se confirma de forma síncrona en Cancelar NF-e y por el estado Cancelada en la consulta.

invoice.cce.registered está reservado en el catálogo (data: invoice_id / chave / n_seq). El endpoint de registro de la CC-e devuelve el protocolo de forma síncrona y por ahora no se entrega ningún callback para él.

Payload de CT-e

Mismo registro y mismas cabeceras que la NF-e; vea CT-e para el flujo de emisión. El objeto de resultado usa tipo = CT-e con orden fijo de campos.

EventoDisparocteStatus
cte.authorizedAutorización 100Autorizada
cte.rejectedRechazo de SEFAZ (cStat distinto de 100)Negada (cteMotivoStatus es cStat - motivo); un fallo terminal de la tarea (Falha) no genera callback, use la API de consulta
cte.canceledCancelación 135Cancelada
cte.event.registeredCualquier otro evento registradoSobre de evento, data trae event_code / n_seq / protocolo

Autorizado:

json
{
"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": "..."
}
CampoTipoDescripción
tipostringSiempre CT-e
empresaIdstringIdentificador de la empresa
cteIdstringEl id enviado en la emisión
cteStatusstringAutorizada / Negada / Cancelada
cteMotivoStatusstring | nullcStat - motivo cuando está rechazado; null en los demás casos
cteLinkDactestring | nullEnlace de descarga del PDF del DACTE (renderizado en la primera descarga); null cuando está rechazado
cteLinkXmlstring | nullEnlace de descarga del XML autorizado; null cuando está rechazado
cteNumerostringNúmero del documento
cteSeriestringSerie
cteChaveAcessostringClave de acceso de 44 dígitos
cteDataEmissaostringFecha de emisión, ISO-8601 UTC
cteDataAutorizacaostring | nullFecha de autorización; null cuando está rechazado
cteNumeroProtocolostring | nullNúmero de protocolo de autorización; null cuando está rechazado
cteDigestValuestring | nullDigest de la firma del XML autorizado

cteLinkDacte y cteLinkXml son utilizables en cuanto llega el callback de autorización; los enlaces no necesitan cabeceras de firma y redirigen con 302, vea Descarga de archivos.

Evento registrado (cte.event.registered), por ejemplo un comprobante de entrega (110180):

json
{
"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"
}
}
CampoTipoDescripción
event_codestringCódigo del evento en SEFAZ, por ejemplo 110110 carta de corrección, 110180 comprobante de entrega
n_seqstringNúmero de secuencia del evento
protocolostringNúmero de protocolo del evento

Payload de DC-e

Mismo registro y mismas cabeceras que la NF-e; vea DC-e para el flujo de emisión. El objeto de resultado usa tipo = DC-e con 14 campos en orden fijo.

EventoDisparodceStatus
dce.authorizedAutorización 100Autorizada
dce.rejectedRechazo de SEFAZ o fallo terminal de la tarea (incluidos los fallos antes de numerar el documento, que no tienen chave)Negada (dceMotivoStatus es cStat - motivo; un fallo terminal sin cStat trae solo el motivo)
dce.canceledCancelación registrada (135 / 136 / 155)Cancelada (dceDataAutorizacao es el momento de registro de la cancelación, dceNumeroProtocolo el protocolo del evento de cancelación)
dce.cancel_rejectedSEFAZ rechazó la cancelación o la tarea de cancelación falló de forma terminalCancelamentoNegado (el documento sigue autorizado; trae el protocolo de autorización / digest / enlace del XML)

Autorizada:

json
{
"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="
}

Rechazada:

json
{
"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
}

Fallo antes de la numeración (empresa no configurada para DC-e, serie ausente, fallo de mapeo del mensaje y otros fallos terminales en los que el documento nunca fue numerado y no tiene chave): dce.rejected se entrega igualmente, los campos de hecho del documento son null, dceMotivoStatus trae el motivo del fallo y dceDataEmissao el momento de aceptación. Correlacione por dceId y nunca suponga que dceChaveAcesso está presente:

json
{
"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
}

Cancelada:

json
{
"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
}

Cancelación rechazada:

json
{
"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="
}
CampoTipoDescripción
tipostringSiempre DC-e
empresaIdstringIdentificador de la empresa
dceIdstringEl id enviado en la emisión; clave de correlación de todos los eventos, presente incluso cuando el documento nunca fue numerado
dceStatusstringAutorizada / Negada / Cancelada / CancelamentoNegado
dceMotivoStatusstring | nullcStat - motivo en un rechazo de SEFAZ; solo el motivo en un fallo terminal sin cStat; null en éxito
dceLinkDacestring | nullEnlace de descarga del PDF del DACE, renderizado en la primera descarga; null cuando está rechazada y en CancelamentoNegado
dceLinkXmlstring | nullEnlace de descarga del XML autorizado; null cuando está rechazada
dceNumerostring | nullNúmero del documento; null cuando nunca fue numerado
dceSeriestring | nullSerie; null cuando nunca fue numerado
dceChaveAcessostring | nullClave de acceso de 44 dígitos; null cuando nunca fue numerado
dceDataEmissaostringFecha de emisión, ISO-8601 UTC (momento de aceptación en un fallo antes de la numeración)
dceDataAutorizacaostring | nullFecha de autorización; momento de registro de la cancelación en Cancelada; null cuando está rechazada
dceNumeroProtocolostring | nullProtocolo de autorización; protocolo del evento de cancelación en Cancelada; null cuando está rechazada
dceDigestValuestring | nullDigest de la firma del XML autorizado; null cuando está rechazada y en Cancelada

En los callbacks de autorización / cancelación, dceLinkDace se renderiza bajo demanda por chave (nada se renderiza en el momento de la entrega; la primera descarga renderiza y archiva). Ambos enlaces comparten la forma de la API de consulta (/openapi/files/{kind}/{ref}?token=...) y su vigencia viene de la configuración del tenant (7 días por defecto), vea Descarga de archivos.

Payload del veredicto de verificación

Cuando la verificación de nivel 2 en SEFAZ se resuelve, la plataforma envía invoice.verify.completed a su URL de webhook. Este es el único canal push para veredictos finales; la consulta por chave puede servir como respaldo por polling.

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": "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)"
}
}

Sobre:

CampoTipoPresenciaDescripción
versionstringsiempreVersión del esquema del payload, actualmente 1.0
event_idstringsiempreId del evento, clave de idempotencia, idéntico entre reintentos
event_typestringsiempreinvoice.verify.completed
occurred_atstringsiempreMomento del evento, ISO-8601 UTC
dataobjectsiempreCuerpo del veredicto, abajo

data:

CampoTipoPresenciaDescripción
chaveAcessostringsiempreClave de acceso de 44 dígitos de la factura verificada, la clave de unión con su envío
validationStatusstringsiempreVeredicto final: VALIDATED / REJECTED / VALIDATION_ERROR, vea Verificación de NF-e
statusstringsiempreEstado fiscal en SEFAZ: Autorizada / Cancelada / Denegada / Inutilizada / NaoEncontrada / Desconhecida
cStatstring | nullanulableCódigo de retorno crudo de SEFAZ (por ejemplo 100 autorizada, 101 cancelada); null cuando no se alcanzó SEFAZ
xMotivostring | nullanulableMensaje de retorno crudo de SEFAZ (portugués, sin cambios)
protocoloobject | nullanulableObjeto de protocolo (numero, digestValue) del registro oficial
dataAutorizacaostring | nullanulableFecha de autorización en SEFAZ, ISO-8601 UTC
eventos[]arraysiempre (puede estar vacío)Eventos fiscales registrados sobre la factura (cancelación, cartas de corrección); vacío cuando no hay
verifiedAtstringsiempreCuándo se completó la verificación de nivel 2, ISO-8601 UTC
reasonstringsolo en falloPresente solo en REJECTED / VALIDATION_ERROR; texto estable en inglés que explica el veredicto
validationStatusTerminalAcción
VALIDATEDSeguro continuar (liberar mercancía, liquidar)
REJECTEDNo continúe; reason explica el veredicto de SEFAZ (cancelada / denegada / inutilizada / no encontrada / protocolo divergente)
VALIDATION_ERRORNoFallo de verificación del lado de la plataforma, no es un juicio sobre la factura; reenvíe más tarde por la verificación de XML con la cabecera forceRevalidate: true

Los payloads de webhook solo traen valores de enumeración independientes del idioma; no hay campos *Description. El texto de presentación queda a cargo del receptor.

Reintentos y circuit breaker

  • Cualquier estado 2xx confirma la entrega. Cualquier otro estado, un error de red o la falta de respuesta en 10 segundos cuenta como fallo.
  • Las entregas fallidas se reintentan con backoff tras el intento inicial:
text
1 min, 5 min, 30 min, 2 h, 6 h
  • Cinco reintentos (seis intentos en total, en unas 8,6 horas). Después, la entrega queda en la cola de dead-letter; la plataforma puede reenviarla manualmente a petición. Cada intento se registra con su estado HTTP, duración y resumen de la respuesta, que es la evidencia usada al investigar un callback ausente.
  • Circuit breaker: los fallos de entrega consecutivos (10 por defecto) desactivan automáticamente el webhook. Mientras está desactivado no se encola ningún evento nuevo para él. Llamar de nuevo a Registrar webhook (o revalidar y guardar la URL en la consola) restaura la entrega y pone a cero el contador de fallos; pida a la plataforma el reenvío de los eventos generados mientras el webhook estaba desactivado.
  • La URL del webhook y el token se releen antes de cada intento, por lo que un nuevo registro surte efecto en el siguiente reintento.

Requisitos del receptor

  1. Responda 2xx en menos de 10 segundos. Buena práctica: persista la entrega cruda, responda 2xx de inmediato y procese de forma asíncrona.
  2. Verifique el origen: recalcule el HMAC sobre los bytes crudos recibidos y compare la cabecera token con el valor registrado.
  3. Deduplique por el id del evento (X-Tffiscal-Event-Id o event_id en los payloads con sobre).
  4. URL pública: una URL absoluta http / https accesible desde internet, de como máximo 500 caracteres. Las direcciones de loopback, privadas y link-local se rechazan en el registro.
  5. Responda a la entrega de prueba: guardar la URL en la consola envía un evento con X-Tffiscal-Event: webhook.verify; responda 2xx sin ningún procesamiento de negocio.
  6. Condicione las acciones de negocio a los campos de resultado (nfeStatus, cteStatus, dceStatus, validationStatus), nunca solo a la respuesta síncrona de la API.
  7. Nunca suponga que los campos opcionales están presentes: dceChaveAcesso puede ser null, reason solo aparece en fallo y los campos vacíos de NF-e llegan como valores vacíos.

Solución de problemas

¿No llegan los callbacks?

Confirme que la URL es una dirección https/http accesible públicamente y que responde 2xx en menos de 10 segundos. La plataforma reintenta con backoff y desactiva el webhook tras fallos consecutivos; llamar de nuevo al endpoint de registro restaura la entrega.

¿La firma falla siempre?

La causa más común es deserializar el payload y volver a serializarlo antes de calcular el HMAC, lo que cambia el orden de los campos o los espacios. Calcule siempre el hash sobre los bytes crudos recibidos y concatene el valor de X-Tffiscal-Timestamp exactamente como lo recibió.

¿El mismo evento llegó dos veces?

Es lo esperado con la entrega al menos una vez. Deduplique por el id del evento; el id de entrega varía por destino y no debe usarse para eso.