Ir al contenido

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.

EventoCuándo llegadata.object
invoice.stampedSe 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.failedEl 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_pendingPidió 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.canceledSe 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_rejectedEl 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_paidLlegó 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.paidUna 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.stampedSe timbró una constancia de retenciones.Constancia (ret_…), status: "valid"
retention.failedEl SAT o el PAC rechazaron una constancia.Constancia, status: "rejected"
retention.canceledSe canceló una constancia (no piden aceptación).Constancia, status: "canceled"
invoice_batch.completedYa 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.readyUn 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.completedUn cliente mandó sus datos fiscales con una liga.Solicitud (cdr_…), status: "completed"
sat_download.completedTerminó una descarga del SAT: sus facturas ya están en GET /v1/sat_invoices.Descarga (sdl_…), status: "completed"
sat_download.failedEl SAT no pudo terminar una descarga; el motivo viene en error.Descarga (sdl_…), status: "failed"
sat_declaration.createdUna consulta de declaraciones encontró una nueva. Sus archivos y cifras pueden llegar después.Declaración (dcl_…)
sat_declaration.updatedLlegó el PDF, el acuse, los estados financieros o las cifras de una declaración.Declaración (dcl_…), como queda
ticket.invoicedSu 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.failedUna 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.viewedEl cliente abrió por primera vez la liga de una cotización.Cotización (quo_…)
quote.acceptedSe aceptó una cotización: el cliente en su liga o usted con POST /v1/quotes/{id}/accept.Cotización, con decided_by
quote.changes_requestedEl 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.rejectedEl cliente rechazó la cotización en su liga.Cotización, con su motivo en customer_comment
ticket.included_in_globalUn ticket que nadie facturó entró en la factura global del periodo.Ticket (tkt_…), status: "global", con la factura global en global_invoice
balance.lowA 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.readyTerminó 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.

invoice.stamped
{
  "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", "...": "..." }
  }
}
CampoQué es
idEl id del evento. Es el mismo en el encabezado webhook-id y en cada reintento: úselo para no procesarlo dos veces.
typeUno de los eventos de la tabla.
api_versionLa versión de la API con la que se armó el objeto.
livemodefalse si viene de pruebas (fk_test_), true de producción.
created_atCuándo pasó, hora de la Ciudad de México.
data.objectEl 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:

EncabezadoQué es
webhook-idEl id del evento (evt_…). Es el mismo en cada reintento.
webhook-timestampCuándo se mandó este intento, en segundos desde 1970.
webhook-signaturev1, 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).

Node.js (Express) con el SDK
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);
});
Python (Flask) con el SDK
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 "", 200

Sin librería

La firma es el HMAC-SHA256 de {webhook-id}.{webhook-timestamp}.{cuerpo} con su secreto sin el prefijo whsec_, decodificado de base64:

verificar.py
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:

  1. Agregue un webhook de pruebas con la dirección de su túnel (vea arriba).
  2. Timbre una factura con su llave de pruebas: con curl, con un SDK o con Probar en la referencia. Le llega invoice.stamped.
  3. Para invoice.failed, timbre a uno de los RFC de prueba, como NOM010101FK9; para invoice.canceled, cancele la factura con "motive": "02".
  4. 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 2xx en 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-id de 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 status del objeto que trae el evento o consúltelo con GET.

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 }'