FISCALISTO / API
Webhooks
Reciba en su servidor cada factura timbrada, rechazada o cancelada, con eventos firmados que se reintentan durante un día.
Un webhook es una dirección de su servidor a la que Fiscalisto manda un evento cada vez que cambia un comprobante de su cuenta. Con ellos su sistema se entera de lo que no pasa en la respuesta de una solicitud: una cancelación que el cliente acepta días después, una factura que el PAC tardó en timbrar o un lote que terminó.
Tipos de evento
Son todos los que hay. Cada uno trae en data.object el objeto completo, igual que su GET, con el estado que tenía cuando pasó. Abra uno para ver el evento entero tal como llega, campo por campo.
| Evento | Cuándo llega | data.object |
|---|---|---|
invoice.stamped | Se timbró un CFDI: factura, complemento de pago, nota de crédito, anticipo, su nota de aplicación, sustitución o recibo de nómina. Llega también cuando uno que quedó en processing (el PAC no contestó) se timbra después. | Factura (inv_…), status: "valid" |
invoice.failed | El SAT o el PAC rechazaron un CFDI. Llega aunque la solicitud ya le haya contestado 422, y cuando uno en processing termina rechazado. | Factura, status: "rejected", con error |
invoice.cancellation_pending | Pidió la cancelación y el cliente debe aceptarla en su buzón tributario (vea cuándo). Sigue vigente mientras tanto. | Factura, status: "pending", cancellation.status: "pending" |
invoice.canceled | Se canceló: de inmediato, o porque el cliente aceptó o no contestó en 3 días hábiles. | Factura, status: "canceled", cancellation.status: "accepted" |
invoice.cancellation_rejected | El cliente rechazó la cancelación, o el SAT nunca recibió la solicitud. Sigue vigente; puede pedirla otra vez. | Factura, status: "valid", cancellation.status: "rejected" o "lapsed" |
invoice.partially_paid | Llegó un pago por una parte de una factura PPD: un complemento de pago o una nota de crédito. Ver Cuentas por cobrar. | Factura, con lo que falta en amount_due |
invoice.paid | Una factura de ingreso ya no debe nada: su último complemento, una nota de crédito, un depósito conciliado o mark_paid. | Factura, con payment_status: paid |
retention.stamped | Se timbró una constancia de retenciones. | Constancia (ret_…), status: "valid" |
retention.failed | El SAT o el PAC rechazaron una constancia. | Constancia, status: "rejected" |
retention.canceled | Se canceló una constancia (no piden aceptación). | Constancia, status: "canceled" |
invoice_batch.completed | Ya no queda ninguna factura del lote por timbrar: cada una se timbró o falló. Cada factura del lote manda además su invoice.stamped o invoice.failed. | Lote (bat_…), status: "completed" |
issuer.ready | Un emisor de producción ya puede timbrar: tiene su CSD y firmó el contrato con el PAC. En pruebas, al terminar una liga de alta. | Emisor (iss_…), status: "active" |
customer_data_request.completed | Un cliente mandó sus datos fiscales con una liga. | Solicitud (cdr_…), status: "completed" |
sat_download.completed | Terminó una descarga del SAT: sus facturas ya están en GET /v1/sat_invoices. | Descarga (sdl_…), status: "completed" |
sat_download.failed | El SAT no pudo terminar una descarga; el motivo viene en error. | Descarga (sdl_…), status: "failed" |
sat_declaration.created | Una consulta de declaraciones encontró una nueva. Sus archivos y cifras pueden llegar después. | Declaración (dcl_…) |
sat_declaration.updated | Llegó el PDF, el acuse, los estados financieros o las cifras de una declaración. | Declaración (dcl_…), como queda |
ticket.invoiced | Su comprador facturó un ticket en el portal de autofactura. Llega también el invoice.stamped de su factura. | Ticket (tkt_…), status: "invoiced", con la factura en invoice |
recurring_invoice.failed | Una factura recurrente no pudo timbrar su periodo (sin timbres, el cliente borrado, el SAT la rechazó). Llega una vez; se intenta de nuevo cada día. | Factura recurrente (rin_…), con el motivo en last_error |
quote.viewed | El cliente abrió por primera vez la liga de una cotización. | Cotización (quo_…) |
quote.accepted | Se aceptó una cotización: el cliente en su liga o usted con POST /v1/quotes/{id}/accept. | Cotización, con decided_by |
quote.changes_requested | El cliente pidió cambios en la liga. A lo más una vez cada 10 minutos. | Cotización, con lo que escribió en customer_comment |
quote.rejected | El cliente rechazó la cotización en su liga. | Cotización, con su motivo en customer_comment |
ticket.included_in_global | Un ticket que nadie facturó entró en la factura global del periodo. | Ticket (tkt_…), status: "global", con la factura global en global_invoice |
balance.low | A la cuenta le quedan pocos timbres de producción: al bajar a low_balance_at y otra vez al acabarse, una vez cada uno hasta que vuelva a tener más. Ver Qué cuesta un timbre. | Saldo (object: "balance"), como GET /v1/balance; siempre livemode: true |
test_clock.ready | Terminó de adelantarse un reloj de prueba: ya se hizo lo de cada día. Solo en pruebas. | Reloj de prueba (object: "test_clock") |
Lo que no manda evento: crear o cambiar clientes, productos, emisores y webhooks (eso lo hace su sistema y ya tiene la respuesta), y los envíos por correo.
{
"id": "evt_0PKhIdhYuAGwzmMQYfkc",
"object": "event",
"type": "invoice.stamped",
"api_version": "2026-10-24",
"livemode": false,
"created_at": "2026-10-24T10:15:42-06:00",
"data": {
"object": { "id": "inv_OufpKqnrvx9p28SINJVjjWKYRhojBTJb8P", "object": "invoice", "status": "valid", "...": "..." }
}
}| Campo | Qué es |
|---|---|
id | El id del evento. Es el mismo en el encabezado webhook-id y en cada reintento: úselo para no procesarlo dos veces. |
type | Uno de los eventos de la tabla. |
api_version | La versión de la API con la que se armó el objeto. |
livemode | false si viene de pruebas (fk_test_), true de producción. |
created_at | Cuándo pasó, hora de la Ciudad de México. |
data.object | El objeto completo. |
La página de cada evento (en la tabla de arriba) tiene un ejemplo completo de lo que manda, y GET /v1/events devuelve los de los últimos 30 días.
Agregar un webhook
Cada url va en un solo endpoint por modo, para que ningún evento llegue dos veces: crear otro con la misma contesta 400 endpoint_exists con el id del que ya la tiene, para cambiarlo con POST /v1/webhook_endpoints/{id}.
curl https://api.fiscalisto.com/v1/webhook_endpoints \
-H "Authorization: Bearer $FISCALISTO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://mi-plataforma.mx/webhooks/fiscalisto",
"events": ["invoice.stamped", "invoice.failed", "invoice.canceled"]
}'La respuesta trae el secret (whsec_…) una sola vez: guárdelo en su servidor. Sin events recibe todos. También puede agregarlo en Desarrolladores › Webhooks. Cada modo tiene sus propios webhooks: uno creado con una llave fk_test_ solo recibe eventos de pruebas.
La dirección debe ser https y pública. No se siguen redirecciones: use la dirección final. Para probar en su computadora no hace falta un túnel: npx fiscalisto listen --forward-to http://localhost:3000/webhooks/fiscalisto le reenvía los eventos de su cuenta de pruebas firmados igual que los de verdad, y npx fiscalisto trigger invoice.stamped provoca uno (vea CLI). Si prefiere registrar un endpoint de verdad, abra un túnel (cloudflared tunnel --url http://localhost:3000 o ngrok http 3000) y registre la dirección https que le da.
Verificar la firma
Cada evento llega como POST con tres encabezados, según Standard Webhooks:
| Encabezado | Qué es |
|---|---|
webhook-id | El id del evento (evt_…). Es el mismo en cada reintento. |
webhook-timestamp | Cuándo se mandó este intento, en segundos desde 1970. |
webhook-signature | v1, y la firma HMAC-SHA256 en base64. Puede traer varias separadas por espacio. |
Verifique siempre la firma antes de confiar en el evento: cualquiera puede mandar un POST a su dirección. Con los SDKs es una línea y no necesita la llave de la API: verifyWebhook(cuerpo, encabezados, secreto) en TypeScript, verify_webhook(…) en Python, Webhook::verify(…) en PHP, fiscalisto.VerifyWebhook(…) en Go, Webhooks.verify(…) en Java y FiscalistoClient.Webhooks.Verify(…) en C#. En otro lenguaje, la librería standardwebhooks hace lo mismo (Ruby, Rust y los demás).
import express from "express";
import { verifyWebhook } from "fiscalisto";
const app = express();
// El cuerpo tal como llegó, sin convertirlo a JSON: la firma es de esos bytes.
app.post("/webhooks/fiscalisto", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = await verifyWebhook(req.body, req.headers, process.env.FISCALISTO_WEBHOOK_SECRET);
} catch {
return res.status(400).send("Firma inválida");
}
res.sendStatus(200); // Responda rápido; procese después.
procesar(event);
});import os
from flask import Flask, request
from fiscalisto import FiscalistoWebhookError, verify_webhook
app = Flask(__name__)
@app.post("/webhooks/fiscalisto")
def fiscalisto():
try:
event = verify_webhook(
request.get_data(), request.headers, os.environ["FISCALISTO_WEBHOOK_SECRET"]
)
except FiscalistoWebhookError:
return "Firma inválida", 400
procesar(event)
return "", 200Sin librería
La firma es el HMAC-SHA256 de {webhook-id}.{webhook-timestamp}.{cuerpo} con su secreto sin el prefijo whsec_, decodificado de base64:
import base64, hashlib, hmac, json, time
def verificar(secreto, headers, cuerpo: bytes):
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("Evento viejo: puede ser una repetición maliciosa")
llave = base64.b64decode(secreto.removeprefix("whsec_"))
firmado = f"{msg_id}.{timestamp}.".encode() + cuerpo
esperada = base64.b64encode(hmac.new(llave, firmado, hashlib.sha256).digest()).decode()
for firma in headers["webhook-signature"].split(" "):
version, _, valor = firma.partition(",")
if version == "v1" and hmac.compare_digest(valor, esperada):
return json.loads(cuerpo)
raise ValueError("Firma inválida")Rechace los eventos con más de 5 minutos de diferencia en webhook-timestamp: así nadie puede repetirle uno viejo.
Probar sus webhooks
Con una llave fk_test_ todo pasa en pruebas y sus webhooks de pruebas reciben los eventos de verdad:
- Agregue un webhook de pruebas con la dirección de su túnel (vea arriba).
- Timbre una factura con su llave de pruebas: con
curl, con un SDK o con Probar en la referencia. Le llegainvoice.stamped. - Para
invoice.failed, timbre a uno de los RFC de prueba, comoNOM010101FK9; parainvoice.canceled, cancele la factura con"motive": "02". - En Desarrolladores › Webhooks vea cada entrega con lo que contestó su servidor, y reenvíela cuantas veces quiera mientras corrige su código. Desarrolladores › Eventos tiene todos los eventos, con lo que se mandó y cada entrega.
Un evento de prueba, sin timbrar
Para probar su código con cualquier evento, mande uno de prueba a su webhook. Le llega en ese momento, una sola vez, firmado como los reales, con un objeto de ejemplo (sus id no existen), aunque el webhook no escuche ese tipo:
curl https://api.fiscalisto.com/v1/webhook_endpoints/whe_8Kq2ZtR4mNw1Lp0sXc7V/test \
-H "Authorization: Bearer $FISCALISTO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "invoice.failed" }'La respuesta trae el evento que se mandó y lo que contestó su servidor:
{
"object": "webhook_test",
"event": { "id": "evt_0PKhIdhYuAGwzmMQYfkc", "object": "event", "type": "invoice.failed", "...": "..." },
"delivery": { "ok": true, "status": 200, "error": null, "response_body": "{\"received\":true}", "duration_ms": 182 }
}Sin type manda invoice.stamped. En el panel es Mandar evento de prueba, en cada webhook. El evento de prueba no sale en GET /v1/events, pero sí en las entregas del webhook, y puede reenviarlo.
Responder
- Conteste un código
2xxen menos de 15 segundos. Cualquier otro código, una redirección o no contestar a tiempo cuenta como falla. - Conteste primero y procese después (en una cola o en segundo plano), para no pasar de los 15 segundos.
- Un evento puede llegar más de una vez. Guarde el
webhook-idde los que ya procesó e ignore los repetidos. - Los eventos pueden llegar en otro orden. Para saber el estado actual de un comprobante use el
statusdel objeto que trae el evento o consúltelo conGET.
Reintentos
Si su servidor falla, reintentamos el mismo evento durante un día: a los 5 segundos, 5 minutos, 30 minutos, 2 horas, 5 horas, 10 horas y 10 horas. Cada intento trae el mismo webhook-id y un webhook-timestamp nuevo.
Un webhook que falla todos los reintentos queda en status: "failing". Si sigue así 5 días, se apaga ("disabled") y deja de recibir eventos; vuelva a encenderlo con POST /v1/webhook_endpoints/{id} y "disabled": false, o en el panel. En Desarrolladores › Webhooks cada webhook muestra sus últimos 7 días (entregados, fallidos, tasa de error y respuesta promedio) y sus entregas con lo que contestó su servidor, y puede reenviar cualquiera. Reenviar es un intento aparte, ahora: no cambia los reintentos automáticos, y si llega, ya no se reintenta.
Si se le pasaron eventos, GET /v1/events tiene los de los últimos 30 días; filtre por type (uno, o todos los de un recurso con un comodín: invoice.*), por object (el id del comprobante) o con delivery_success=false por los que algún webhook no recibió:
curl "https://api.fiscalisto.com/v1/events?type=invoice.*&delivery_success=false" \
-H "Authorization: Bearer $FISCALISTO_API_KEY"Cambiar el secreto
POST /v1/webhook_endpoints/{id}/roll_secret le da un secreto nuevo. El anterior sigue firmando 24 horas: mientras tanto cada evento trae dos firmas, una con cada secreto, y la verificación pasa con cualquiera. Cambie su servidor al nuevo dentro de ese plazo. Para otro plazo mande previous_secret_expires_in en segundos; si el secreto se filtró, mande 0 y el anterior deja de firmar en ese momento.
curl -X POST https://api.fiscalisto.com/v1/webhook_endpoints/whe_0q1Vmmw52sbDpmtsKD1E/roll_secret \
-H "Authorization: Bearer $FISCALISTO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "previous_secret_expires_in": 0 }'