Webhooks
Los webhooks avisan a tu sistema cuando una factura avanza en su cancelación o cuando cambia una descarga masiva, sin que consultes la API cada pocos minutos. Registras una URL https, eliges los tipos de evento y Fiscalapi le envía un POST firmado cada vez que ocurre uno.
Cómo funciona
- Registras un webhook: la URL que recibe los eventos, los tipos que suscribes y, si quieres, las personas cuyos eventos recibe. Guardas su secreto de firma.
- Un recurso cambia de estado, por ejemplo una factura pasa de Cancelando a Cancelado, y Fiscalapi registra un evento.
- Fiscalapi envía el evento por POST a cada webhook habilitado que lo suscribe, firmado con el secreto de ese webhook.
- Tu receptor verifica la firma, responde 2xx y procesa el evento después. Si no responde 2xx, Fiscalapi reintenta durante unas 75 horas.
El evento solo dice qué recurso cambió y a qué estado llegó; no lleva datos fiscales. Para el detalle, consulta el recurso con la URL que viaja en el evento.
Los webhooks están incluidos en todas las cuentas. Administrarlos requiere el permiso Webhooks.Manage, que tienen el owner y los miembros de la cuenta. La API los acepta con tu API key o con la sesión del dashboard.
Tipos de evento
Cada tipo nombra el estado al que llegó el recurso, con la forma recurso.estado. Fiscalapi emite un evento por transición.
| Tipo | Recurso | Cuándo ocurre |
|---|---|---|
invoice.cancellation_requested | Factura | El PAC aceptó la solicitud de cancelación: la factura pasa de Vigente a Cancelando. |
invoice.canceled | Factura | El SAT confirmó la cancelación: la factura pasa de Cancelando a Cancelado. |
invoice.cancellation_rejected | Factura | La cancelación se rechazó o no procede: la factura vuelve de Cancelando a Vigente. |
download_request.completed | Solicitud de descarga | La solicitud terminó y sus paquetes ya se pueden descargar. |
download_request.abandoned | Solicitud de descarga | Fiscalapi abandonó la solicitud. |
download_rule.suspended | Regla de descarga | Fiscalapi suspendió la regla, por ejemplo por una e.firma vencida o no cargada. |
webhook.test | Webhook | Probaste el webhook. Solo le llega al webhook que probaste y no se suscribe. |
download_request.abandoned no implica un error: el motivo está en el GET de la solicitud. Una causa común es que el SAT no encuentre documentos que cumplan los criterios. Consulta el detalle con Obtener solicitud por ID y el ciclo de vida de las solicitudes.
Ignora los tipos de evento que no conozcas: el catálogo crecerá sin cambiar el sobre. Un tipo nuevo llega con la misma forma; responde 2xx y descártalo.
Códigos de estado
Los campos status y previousStatus llevan los códigos públicos de la API v4, los mismos que devuelve el GET del recurso:
| Recurso | Código | Estado |
|---|---|---|
| Factura | "V" | Vigente |
| Factura | "C" | Cancelando: el SAT todavía no confirma la cancelación. |
| Factura | "X" | Cancelado |
| Solicitud de descarga | "1" | Esperando SAT |
| Solicitud de descarga | "2" | Esperando API |
| Solicitud de descarga | "3" | Completada |
| Solicitud de descarga | "-1" | Abandonada |
| Regla de descarga | "1" | Pendiente |
| Regla de descarga | "2" | Aprobada |
| Regla de descarga | "3" | Rechazada |
| Regla de descarga | "4" | Suspendida |
| Regla de descarga | "-1" | Abandonada |
El estado anterior depende del camino que siguió el recurso. Por ejemplo, una solicitud se abandona desde Esperando SAT o desde Esperando API, y la solicitud simulada del ambiente de pruebas llega a Completada sin estado anterior. Trata ambos campos como texto que puede ser nulo.
El evento
Todos los eventos tienen la misma forma, el sobre. No hay campos propios de un tipo: entre tipos solo cambian los valores.
El cuerpo del POST es el sobre en JSON compacto y UTF-8. Los campos que no aplican viajan como null; nunca se omiten.
Propiedades
- Name
id- Type
- string?
- Description
Id del evento. Es el mismo valor del header
webhook-idy no cambia entre reintentos ni reenvíos.
- Name
type- Type
- string?
- Description
Tipo de evento. Ver Tipos de evento. Trátalo como texto libre: pueden aparecer tipos nuevos.
- Name
createdAt- Type
- string?
- Description
Momento en que ocurrió el cambio, en ISO 8601 y UTC, al segundo y con Z final. No depende del header
X-TIME-ZONE.
- Name
personId- Type
- string?
- Description
Persona dueña del recurso: el emisor de la factura o la persona de la regla de descarga. Puede ser
null.
- Name
data- Type
- object?
El recurso que cambió y sus estados.
- Name
relatedObject- Type
- object?
El recurso al que se refiere el evento.
- Name
id- Type
- string?
- Description
Id del recurso, el mismo que usas en la API.
- Name
type- Type
- string?
- expandible
- Description
Tipo de recurso. Trátalo como texto libre.
- Type
- enum:
- Values
- "invoice""download_request""download_rule"
- Name
url- Type
- string?
- Description
Ruta relativa del GET v4 del recurso, por ejemplo
/api/v4/invoices/seguida del id. Antepón la URL base de tu ambiente para consultarlo.
- Name
status- Type
- string?
- Description
Código del estado al que llegó el recurso, o
null. Ver Códigos de estado.
- Name
previousStatus- Type
- string?
- Description
Código del estado anterior, o
nullsi no aplica.
Eventos
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"type": "invoice.canceled",
"createdAt": "2026-10-12T18:47:53Z",
"personId": "4f6a2c1e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
"data": {
"relatedObject": {
"id": "0b6c1f2e-7d8a-4b9c-8e1f-2a3b4c5d6e7f",
"type": "invoice",
"url": "/api/v4/invoices/0b6c1f2e-7d8a-4b9c-8e1f-2a3b4c5d6e7f"
},
"status": "X",
"previousStatus": "C"
}
}
Los ejemplos están formateados para leerse; el cuerpo real llega en una sola línea, sin espacios. Como la URL es relativa, el mismo evento es idéntico en pruebas y en producción. La URL base es https://test.fiscalapi.com en pruebas y https://live.fiscalapi.com en producción.
personId puede ser nulo (factura emitida por valor) y entonces no llega a webhooks con filtro de personas. Una factura por valores no tiene una persona emisora registrada en tu cuenta, así que su evento solo lo reciben los webhooks sin filtro de personas. Si emites por valores y quieres esos eventos, registra un webhook sin filtro.
Registrar un webhook
Un webhook pertenece a tu cuenta y recibe los eventos de todas sus personas, salvo que lo limites a algunas. Puedes tener hasta 16 webhooks de URL por cuenta.
| Dato | Regla |
|---|---|
| URL | https, absoluta, sin usuario ni contraseña y de hasta 2048 caracteres. No puede apuntar a una dirección privada, local o reservada, ni a localhost. |
| Descripción | Opcional, de hasta 256 caracteres. Te ayuda a reconocer el webhook en el dashboard y en los avisos. |
| Tipos de evento | Al menos uno, o solo el comodín "*" para recibir todos, también los que se agreguen después. El comodín no se combina con otros tipos. |
| Personas | Opcional, hasta 100. Sin personas, el webhook recibe los eventos de todas. Una persona que no es de tu cuenta se rechaza. |
Para recibir eventos en tu computadora, usa el CLI: ver Probar tu receptor.
Desde el dashboard
Ve a Developers > Webhooks y presiona Agregar webhook. Captura la URL, la descripción, los eventos (Todos o los tipos que elijas) y, si quieres, las personas. Al guardar, el dashboard muestra el secreto de firma con el aviso "Guarda este secreto de firma. Lo usarás para verificar que los eventos vienen de FiscalAPI." Presiona Copiar y guárdalo en la configuración segura de tu receptor.
La página Webhooks tiene estas acciones:
| Acción | Dónde | Qué hace |
|---|---|---|
| Editar, Deshabilitar, Habilitar y Eliminar | Menú Acciones del webhook | Cambian el webhook. Ver Reemplazar webhook. |
| Revelar secreto | Menú Acciones del webhook | Muestra el secreto vigente sin cambiarlo. Cada revelación queda registrada. |
| Rotar secreto | Menú Acciones del webhook | Pide confirmación y muestra el secreto nuevo. Ver Rotar el secreto. |
| Entregas | Tabla Entregas, al seleccionar un webhook | El log de sus entregas: Evento, Tipo, Estado, Intentos, Último código, Motivo, Latencia, Extracto de la respuesta y Próximo intento. Recargar lo actualiza. |
| Reenviar | Menú Acciones de una entrega | Reenvía su evento al webhook seleccionado, en un solo intento. |
| Enviar evento de prueba | Barra de la tabla Entregas | Manda un evento de prueba al webhook seleccionado. Ver Probar tu receptor. |
| Eliminar | Tabla Escuchas de CLI | Las escuchas de fiscalapi listen se ven aquí y son de solo lectura: solo se eliminan. |
Reenviar y Enviar evento de prueba solo aparecen con el webhook habilitado. Revelar secreto y Rotar secreto no aparecen mientras alguien de soporte accede a tu cuenta en tu nombre.
Con la API
Da de alta el webhook con Crear webhook. La respuesta trae el secreto en secret; con la API key solo lo vuelves a obtener al rotarlo, así que guárdalo al recibirla. Después, pruébalo con Probar webhook y revisa el resultado en Listar entregas.
El secreto es el prefijo whsec_ seguido de 44 caracteres en base64: 32 bytes aleatorios, con un signo igual al final. Guárdalo completo, con su prefijo.
Verificar la firma
Cada entrega sigue la especificación abierta Standard Webhooks y lleva tres headers:
| Header | Contenido |
|---|---|
webhook-id | Id del evento, igual al id del sobre y el mismo en cada reintento. |
webhook-timestamp | Momento del envío de ese intento, en segundos Unix. |
webhook-signature | Una o más firmas separadas por un espacio, cada una con el formato v1, coma y la firma en base64. |
La firma es la única prueba de autenticidad de un evento. Fiscalapi no publica las direcciones IP de sus servidores ni recomienda filtrar las entregas por IP: esas direcciones pueden cambiar sin aviso. Rechaza toda entrega cuya firma no coincida con la que calculas.
La firma es un HMAC-SHA256 en base64 sobre el texto {id}.{timestamp}.{body}: el id del evento, un punto, el timestamp, otro punto y el cuerpo crudo, tal como llegan. La llave del HMAC es el secreto sin el prefijo whsec_, decodificado de base64.
Para verificar una entrega:
- Lee el cuerpo crudo, exactamente los bytes que llegaron, antes de que tu framework lo convierta en un objeto.
- Rechaza la entrega si el timestamp se aleja más de 5 minutos de tu reloj. Así nadie repite después una entrega capturada.
- Calcula la firma con cada secreto vigente. Durante una rotación tienes dos.
- Acepta la entrega si alguna firma v1 del header coincide con la tuya. Compara en tiempo constante, nunca con una comparación de texto normal.
- Solo entonces convierte el cuerpo en JSON y procésalo.
Verifica sobre el cuerpo crudo. Si tu framework convierte el JSON en un objeto y lo vuelves a serializar, el texto cambia (espacios, orden o escapes) y la firma no coincide aunque el evento sea legítimo.
Con el SDK
Los SDK oficiales hacen estos pasos por ti. Su función de verificación recibe los headers de la entrega, el cuerpo crudo y tu secreto, o una lista de secretos durante una rotación, y devuelve el evento ya verificado:
Recibir un evento
using System.Text;
using System.Threading.Channels;
using Fiscalapi.Models;
using Fiscalapi.Webhooks;
// ASP.NET Core. El secreto vigente y, durante una rotación, también el anterior.
var secrets = builder.Configuration.GetSection("FiscalApi:WebhookSecrets").Get<string[]>()!;
var events = Channel.CreateUnbounded<WebhookEvent>(); // ilustrativa: en producción, una cola durable
app.MapPost("/webhooks/fiscalapi", async (HttpRequest request) =>
{
// El cuerpo crudo, tal como llegó.
using var reader = new StreamReader(request.Body, Encoding.UTF8);
var body = await reader.ReadToEndAsync();
var headers = request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString());
WebhookEvent webhookEvent;
try
{
webhookEvent = Webhook.Verify(headers, body, secrets);
}
catch (WebhookVerificationException)
{
return Results.BadRequest(); // firma inválida, header faltante o timestamp fuera de tolerancia
}
await events.Writer.WriteAsync(webhookEvent); // procésalo después, fuera de la petición
return Results.Ok();
});
La verificación compara en tiempo constante, acepta cualquier firma v1 válida del header y rechaza un timestamp con más de 5 minutos de diferencia. Si la entrega no verifica, lanza la excepción de verificación: responde 400 y no la proceses. Un secreto mal copiado o una lista vacía lanzan un error de configuración, que nunca incluye el valor del secreto.
| SDK | Función | Entrega que no verifica | Secreto mal configurado |
|---|---|---|---|
| .NET | Webhook.Verify | WebhookVerificationException | ArgumentException |
| Node.js | Webhook.verify | WebhookVerificationError | TypeError |
| Python | fiscalapi.webhooks.verify | WebhookVerificationException | ValueError |
| Java | Webhook.verify | WebhookVerificationException | IllegalArgumentException |
| PHP | Webhook::verify | WebhookVerificationException | InvalidArgumentException |
Las clases viven en Fiscalapi.Webhooks en .NET, en com.fiscalapi.webhooks en Java y en Fiscalapi\Webhooks en PHP. En Node.js las exporta el paquete fiscalapi.
A mano
Sin el SDK, cualquier librería de Standard Webhooks verifica las entregas de Fiscalapi con tu secreto tal cual, con su prefijo. También puedes implementarlo con la librería estándar de tu lenguaje:
Verificar la firma
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public static class WebhookVerifier
{
private const long ToleranceSeconds = 5 * 60;
// id, timestamp y signature son los headers webhook-id, webhook-timestamp y webhook-signature.
// rawBody es el cuerpo crudo, tal como llegó, sin deserializar.
// secrets es una lista de secretos whsec_: el vigente y, durante una rotación, también el anterior.
public static JsonDocument Verify(string id, string timestamp, string signature, byte[] rawBody,
IEnumerable<string> secrets, DateTimeOffset? now = null)
{
var nowSeconds = (now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds();
if (string.IsNullOrEmpty(id) || !long.TryParse(timestamp, out var seconds) ||
seconds < nowSeconds - ToleranceSeconds || seconds > nowSeconds + ToleranceSeconds)
throw new CryptographicException("Timestamp fuera de la tolerancia");
var content = Encoding.UTF8.GetBytes($"{id}.{timestamp}.").Concat(rawBody).ToArray();
var expected = secrets
.Select(secret => Convert.FromBase64String(secret.StartsWith("whsec_") ? secret[6..] : secret))
.Select(key => HMACSHA256.HashData(key, content))
.ToList();
foreach (var entry in (signature ?? "").Split(' '))
{
var parts = entry.Split(',', 2);
var received = new byte[HMACSHA256.HashSizeInBytes];
if (parts.Length == 2 && parts[0] == "v1" &&
Convert.TryFromBase64String(parts[1], received, out var written) && written == received.Length &&
expected.Any(e => CryptographicOperations.FixedTimeEquals(e, received)))
return JsonDocument.Parse(rawBody);
}
throw new CryptographicException("Firma inválida");
}
}
Prueba tu implementación con el vector oficial de Standard Webhooks. Con la hora actual igual al timestamp del vector, la firma verifica; si cambias un carácter del cuerpo, de la firma o del secreto, falla:
| Dato | Valor |
|---|---|
| Secreto | whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw |
webhook-id | msg_p5jXN8AQM9LWM0D4loKWxJek |
webhook-timestamp | 1614265330 |
| Cuerpo | {"test": 2432232314} |
webhook-signature | v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= |
Responder a una entrega
Cada entrega es un POST con Content-Type: application/json; charset=utf-8 y el User-Agent FiscalAPI-Webhooks/1. Fiscalapi espera la respuesta hasta 15 segundos en total, incluidos 5 para conectar, y guarda en el log el código y el primer KB de la respuesta.
Responde 2xx rápido y procesa después. Un 3xx cuenta como falla y un 410 deshabilita el webhook. Fiscalapi no sigue redirecciones: si cambias la URL, actualiza el webhook.
| Respuesta | Qué hace Fiscalapi |
|---|---|
| 2xx | Da la entrega por recibida. El cuerpo de tu respuesta no importa. |
| 3xx | Falla: reintenta según el calendario. |
| 410 | Falla y deshabilita el webhook de inmediato: no recibe más eventos hasta que lo habilites. |
| 429 o 503 con Retry-After | Falla: reintenta cuando toque en el calendario o cuando pediste, lo que sea más tarde, con un máximo de 24 horas. |
| Otro código, timeout o error de conexión o de TLS | Falla: reintenta según el calendario. |
Guarda el evento en una cola o una tabla y responde. Haz el trabajo pesado, como consultar el recurso o actualizar tu sistema, fuera de la petición: un receptor que tarda más de 15 segundos acumula reintentos de eventos que sí recibió.
Reintentos
Si una entrega falla, Fiscalapi la reintenta con este calendario, contado desde la falla anterior. Cada espera varía hasta 10 % para que las entregas pendientes no lleguen todas juntas.
| Reintento | Espera |
|---|---|
| 1 | 5 segundos; llega dentro del minuto siguiente. |
| 2 | 5 minutos |
| 3 | 30 minutos |
| 4 | 2 horas |
| 5 | 5 horas |
| 6 | 10 horas |
| 7 | 14 horas |
| 8 | 20 horas |
| 9 | 24 horas |
Son 10 intentos en unas 75 horas: un receptor caído un fin de semana todavía recibe sus eventos. Cada intento lleva un webhook-timestamp nuevo y su propia firma, con el mismo webhook-id y el mismo cuerpo. Los reenvíos y las entregas de prueba son de un solo intento y no siguen este calendario.
La entrega es al menos una vez: tu receptor puede recibir el mismo evento más de una vez, por ejemplo si respondió 2xx pero la respuesta no llegó a Fiscalapi. Deduplica por webhook-id: guarda los ids que procesaste y, si llega uno conocido, responde 2xx sin repetir el trabajo.
El orden no está garantizado: un reintento puede llegar después de un evento más nuevo del mismo recurso. Para el estado autoritativo, haz GET de relatedObject.url con tu URL base y tus credenciales; la API siempre responde el estado actual.
Deshabilitación y avisos
Fiscalapi avisa por correo al owner de la cuenta cuando algo falla de forma persistente, y deshabilita los webhooks que no responden:
| Situación | Qué ocurre |
|---|---|
| Una entrega agota sus reintentos. | El owner recibe el correo "Tu webhook no pudo entregar un evento", como máximo uno cada 24 horas por webhook. Un reenvío o una prueba que fallan no lo generan. |
| Falla un reintento de una entrega y el webhook lleva 5 días o más sin responder ningún 2xx. | Fiscalapi lo deshabilita en esa falla y el owner recibe el correo "Deshabilitamos tu webhook". Sin entregas pendientes, el webhook no se deshabilita solo. |
| El receptor responde 410. | Fiscalapi lo deshabilita de inmediato y el owner recibe el correo "Deshabilitamos tu webhook". |
| Tú lo deshabilitas. | Deja de recibir eventos. No hay correo. |
El dashboard muestra el motivo de un webhook deshabilitado: Por ti, El webhook respondió 410 o 5 días sin respuestas exitosas. En la API, el motivo viaja en disabledReason.
Los eventos generados mientras un webhook está deshabilitado no se le entregan, ni al habilitarlo de nuevo. Al habilitarlo, recupera lo que te faltó con Conciliar eventos.
Probar tu receptor
Envía un evento de prueba con Enviar evento de prueba en el dashboard o con Probar webhook. Llega un evento webhook.test, firmado como cualquier otro, solo al webhook que probaste. Sigue el resultado en el log de entregas.
Las entregas de prueba (las de este botón, las de Probar webhook y las de fiscalapi trigger) se comportan distinto de las demás:
| Regla | Detalle |
|---|---|
| Un solo intento | Si fallan, no se reintentan ni generan el correo de aviso. |
| Su 2xx no cuenta para la salud del webhook | Un webhook que solo responde a pruebas sigue acumulando sus 5 días sin respuestas exitosas. Tampoco cuenta el 2xx de un evento de prueba reenviado. |
| Su falla sí cuenta | Una falla marca el webhook como fallando si no lo estaba, y un 410 lo deshabilita. |
Probar en local
Fiscalapi no entrega a localhost. Para recibir eventos en tu computadora sin exponerla a internet, usa el CLI fiscalapi: lleva los eventos de tu cuenta a tu servidor local, dispara eventos de prueba y reenvía los que te faltaron. Cómo instalarlo y usarlo está en Probar webhooks en local.
Conciliar eventos
Fiscalapi conserva los eventos y sus entregas 30 días. Después de una caída de tu receptor, o al habilitar de nuevo un webhook, Listar eventos te devuelve cada sobre tal como se envió, del más nuevo al más viejo, con filtros por tipo, recurso y rango de fechas.
Compara los id con los que ya procesaste. Procesa los que falten desde el listado o pide que se entreguen de nuevo con Reenviar evento. Un reenvío es un solo intento y llega con el mismo webhook-id, así que tu deduplicación lo reconoce. Un evento de más de 30 días ya no se puede consultar ni reenviar.
Rotar el secreto
Rota el secreto si se expuso o como rutina de seguridad. En el dashboard, Rotar secreto pide esta confirmación: "Se generará un secreto nuevo. El actual seguirá firmando durante 24 horas para que actualices tu receptor sin perder eventos. ¿Continuar?". Con la API, llama a Rotar secreto.
Durante las 24 horas siguientes, cada entrega lleva dos firmas en webhook-signature, una con el secreto nuevo y otra con el anterior. Para cambiar de secreto sin perder eventos:
- Rota el secreto y guarda el nuevo.
- Configura tu receptor con los dos secretos; la verificación acepta la entrega si cualquiera de las firmas coincide.
- Cuando el secreto nuevo ya esté en tu receptor, quita el anterior. Pasadas 24 horas, el anterior deja de firmar.
Si rotas otra vez antes de 24 horas, el secreto anterior conserva su vencimiento original y solo se reemplaza el nuevo. Así un doble clic no deja sin secreto a un receptor que todavía no cambió el suyo.
Para ver el secreto vigente sin rotarlo, usa Revelar secreto en el dashboard. Solo existe en el dashboard, no con la API key, y cada revelación queda registrada. Mientras alguien de soporte accede a tu cuenta en tu nombre, ninguna respuesta incluye el secreto.
Buenas prácticas del receptor
| Práctica | Por qué |
|---|---|
| Verifica la firma de cada entrega y rechaza las que no verifican. | La firma es la única prueba de que el evento viene de Fiscalapi. |
| Verifica sobre el cuerpo crudo. | Volver a serializar el JSON cambia el texto firmado. |
| Responde 2xx en cuanto guardes el evento. | Un receptor lento provoca reintentos y, si pasa 5 días o más sin un 2xx, la deshabilitación del webhook en la siguiente falla de un reintento. |
Deduplica por webhook-id. | La entrega es al menos una vez. |
Consulta relatedObject.url antes de actuar. | El orden no está garantizado y la API tiene el estado actual. |
| Ignora los tipos que no conoces y responde 2xx. | El catálogo crecerá sin cambiar el sobre. |
| Guarda el secreto como guardas tu API key, fuera del código. | Quien tiene el secreto puede firmar eventos falsos. |
| Concilia con la lista de eventos después de una caída. | Lo que se perdió está disponible 30 días. |
Límite de solicitudes
Probar webhook, Reenviar evento y Disparar evento de prueba, que usa fiscalapi trigger, tienen un límite de solicitudes por API key, compartido con la creación de solicitudes de descarga masiva. El dashboard no tiene este límite. Cada respuesta trae tu consumo en los headers RateLimit-Limit, RateLimit-Remaining y RateLimit-Reset.
Si lo excedes, la API responde 429 con el header Retry-After, en segundos, y no ejecuta la operación. Espera ese tiempo y repite la solicitud. El cuerpo de esa respuesta está en Fechas y errores.