Versión 1.0.0 · solo lectura
API de Sereno Fiscal
Conecta tu sistema con Sereno: revisa un RFC en las listas del SAT y OFAC, lee la ficha fiscal de tus empresas y recibe un aviso automático cuando salga una alerta.
Introducción
Una API es una puerta para que tus programas hablen con Sereno sin que nadie abra la pantalla. Esta es de solo lectura: consulta y avisa, pero nunca presenta nada ante el SAT ni toca tu e.firma.
Para quién es
- Quien tiene un ERP, un sistema de compras o de contabilidad y quiere validar a cada proveedor contra las listas antes de pagarle.
- Quien arma automatizaciones en Zapier o Make y quiere que una alerta de Sereno cree una tarea o mande un mensaje.
- Los despachos contables que quieren ver el semáforo de todos sus clientes en su propio tablero.
Cómo obtener tu llave
- Entra a Sereno con una cuenta de administrador y abre Mi cuenta, pestaña API. (En un despacho, la crea el dueño.)
- Elige Crear llave, ponle un nombre que la distinga, marca los permisos que necesita y, si quieres, las empresas y una fecha de vencimiento.
- Copia la llave en cuanto aparezca: se muestra una sola vez. Guárdala en el gestor de contraseñas o en la configuración segura de tu sistema.
La API viene incluida en los paquetes Pro, Max y Corporativo, y se puede añadir al paquete Plus con el complemento de API. Los despachos tienen su propia llave, que crea el dueño del despacho. Si tu paquete no la incluye, la pestaña te lo avisa.
Seguridad
- La llave se ve una sola vez. Sereno solo guarda una huella de ella, así que no podemos mostrártela después. Si la pierdes, rótala o crea otra.
- Da alcances mínimos. Una llave que solo valida proveedores necesita únicamente
listas. Si se filtra, el daño es menor. - Úsala solo desde tu servidor. La API no acepta llamadas desde un navegador a propósito: una llave dentro de una página web quedaría a la vista de cualquiera.
- Hay límites diarios y por minuto por llave (ver Límites) y un tope por dirección de internet para frenar intentos con llaves falsas.
- Rota con calma. Al rotar una llave, la anterior sigue sirviendo 7 días para que cambies tus sistemas sin cortes.
- Revoca sin dudar. Una llave revocada deja de servir en segundos. Sereno te avisa por correo si una llave lleva 90 días sin usarse.
Tu primera consulta
La dirección base es https://api.serenofiscal.mx/v1. Con tu llave en la variable SERENO_LLAVE:
curl "https://api.serenofiscal.mx/v1/listas/DEL120301AB3" \
-H "Authorization: Bearer $SERENO_LLAVE"Autenticación
Manda la llave en el encabezado Authorization de cada petición, con la palabra Bearer y un espacio antes. Una llave tiene esta forma: srn_live_ seguido de 43 letras, números, guiones o guiones bajos.
Authorization: Bearer srn_live_…Para comprobar que tu llave funciona sin gastar cuota, pide el estado. Con llave, además de la salud del servicio verás tu plan, tus límites y lo que llevas gastado hoy:
curl "https://api.serenofiscal.mx/v1/estado" \
-H "Authorization: Bearer $SERENO_LLAVE"Una llave que falta, mal escrita, inexistente, revocada o vencida responde siempre el mismo 401 (llave_invalida). Los permisos de la llave se llaman alcances:
| Alcance | Qué permite |
|---|---|
| listas | Revisar RFC en las listas del SAT y OFAC (/listas/…). No necesita empresas, por eso sirve a un integrador. |
| empresas | Leer la ficha de tus empresas, alertas, declaraciones, cumplimiento, facturas, contrapartes, vencimientos y eventos (/empresas/…, /eventos, /despacho/clientes). |
| documentos | Pedir enlaces temporales a los PDF (…/descarga). |
| webhooks | Crear, listar y borrar avisos automáticos (/webhooks/…). Para suscribirte a eventos de alertas, semáforo o documentos (todos menos ping) la llave necesita también el alcance empresas. |
Si la llave se limitó a ciertas empresas, solo ve esas. Pedir una empresa que no ve da el mismo 404 que una que no existe. Las empresas que el equipo de Sereno tenga suspendidas no aparecen en la API, y si la cuenta fue cerrada recibirás 403 cuenta_suspendida. La API es de servidor a servidor: no admite llamadas desde un navegador (no tiene CORS), para que tu llave nunca viva en una página web.
Convenciones
- Formato. Todo es JSON en UTF-8. Las rutas y los campos están en español (
razon_social,fecha_limite) y las listas cerradas de valores van sin acento (ambar,critica). - Versión. Va en la ruta (
/v1). Agregar campos nuevos no rompe nada: ignora los que no conozcas. Un cambio que rompe sería/v2. - Fechas. Los instantes van en ISO 8601 y en UTC (
2026-10-19T14:03:00Z); las fechas, como2026-10-19; los periodos, como2026-09. Los cortes de día y de mes se hacen en hora de la Ciudad de México. - Dinero. Siempre como texto decimal con dos decimales (
"1250300.00") y su moneda, nunca como número flotante. Lo que viene en otra moneda se convierte a pesos con el tipo de cambio de la factura. - RFC. Se aceptan en minúsculas y con espacios en los bordes; se devuelven en mayúsculas.
- Paginación por cursor. Las listas aceptan
?limite=100(de 1 a 200) y?cursor=…. La respuesta trae{"datos": [...], "siguiente_cursor": null}: mientrassiguiente_cursorno seanull, pídelo comocursorcon los mismos filtros. El cursor es opaco; no lo armes ni lo interpretes. - Idempotency-Key.
POST /listas/loteyPOST /webhooks/{id}/probaraceptan este encabezado (hasta 64 caracteres). Repetir la misma petición con la misma clave devuelve la misma respuesta sin volver a gastar cuota; la misma clave con otro cuerpo da409. Se guarda 24 horas. - Encabezados de respuesta.
X-Id-Peticion(el id que verás también en los errores),X-Limite-DiayX-Restante-Dia(tu cuota del día),Retry-Afteren los 429 eIdempotent-Replayedcuando se repite una respuesta guardada. - Tiempos. Espera hasta 15 segundos antes de dar por perdida una petición.
Referencia de endpoints
Son 20 endpoints. Cada uno indica su alcance, qué cuenta contra tu cuota, sus parámetros, un ejemplo con curl y la respuesta. Todos los datos de los ejemplos son inventados. La misma referencia, para herramientas, está en openapi.json.
Cargando la referencia…
| Método | Ruta | Alcance | Cuenta contra la cuota | Para qué sirve |
|---|---|---|---|---|
| get | /estado | ninguno | No cuenta | Estado del servicio, cortes de las listas y, con llave, tu plan y uso de hoy. |
| get | /listas/{rfc} | listas | 1 consulta de listas | Revisa un RFC en las listas 69-B, 69, 49 Bis y OFAC. |
| post | /listas/lote | listas | 1 por RFC válido y distinto | Revisa hasta 500 RFC de una vez. |
| get | /empresas | empresas | 1 lectura | Empresas de la llave con semáforo y alertas. |
| get | /empresas/{rfc} | empresas | 1 lectura | Ficha fiscal completa de una empresa. |
| get | /empresas/{rfc}/alertas | empresas | 1 lectura | Alertas de una empresa. |
| get | /empresas/{rfc}/declaraciones | empresas | 1 lectura | Declaraciones presentadas y periodos faltantes. |
| get | /empresas/{rfc}/cumplimiento | empresas | 1 lectura | Opiniones de cumplimiento y constancias por mes. |
| get | /empresas/{rfc}/cumplimiento/{id}/descarga | documentos | 1 lectura | Enlace temporal para descargar una opinión o constancia. |
| get | /empresas/{rfc}/declaraciones/{id}/descarga | documentos | 1 lectura | Enlace temporal para descargar una declaración o su acuse. |
| get | /empresas/{rfc}/cfdi/resumen | empresas | 1 lectura | Resumen mensual de facturas e importes. |
| get | /empresas/{rfc}/contrapartes | empresas | 1 lectura | Clientes y proveedores que aparecen en listas. |
| get | /empresas/{rfc}/vencimientos | empresas | 1 lectura | Vencimientos de la e.firma y alertas con fecha límite. |
| get | /eventos | empresas | 1 lectura | Eventos de los últimos 30 días. |
| post | /webhooks | webhooks | 1 lectura | Crea un webhook. |
| get | /webhooks | webhooks | 1 lectura | Lista tus webhooks. |
| delete | /webhooks/{id} | webhooks | 1 lectura | Borra un webhook. |
| post | /webhooks/{id}/probar | webhooks | 1 lectura | Manda un aviso de prueba. |
| get | /webhooks/{id}/entregas | webhooks | 1 lectura | Entregas de un webhook. |
| get | /despacho/clientes | empresas | 1 lectura | Clientes de un despacho (solo llaves de despacho). |
Webhooks: avisos automáticos
Un webhook es una dirección de tu sistema a la que Sereno le manda un aviso cuando ocurre algo, para que no tengas que preguntar una y otra vez. Los creas en Cuenta > API o con POST /webhooks. Al crearlo recibes un secreto (whsec_…) que se muestra una sola vez. Para eventos que no sean ping, la llave que lo crea debe tener los alcances webhooks y empresas.
Eventos
| Evento | Cuándo se manda | Campos de «datos» |
|---|---|---|
| alerta.creada | Sereno abre una alerta en una de tus empresas. | alerta_id, severidad, regla, titulo, rfc_contraparte, fecha_limite |
| alerta.cerrada | Una alerta se cierra. | alerta_id, severidad, regla, titulo, rfc_contraparte |
| semaforo.cambiado | El semáforo de una empresa cambia de color. | color_anterior, color_nuevo, motivos |
| documento.nuevo | Llega una opinión de cumplimiento, una constancia o una declaración. | tipo, documento_id, mes, sentido, estatus_padron |
| contraparte.en_lista | Un cliente o proveedor de tus empresas aparece en una lista del SAT u OFAC. | alerta_id, rfc_contraparte, lista, situacion, relacion, severidad |
| ping | Tú lo pides con «Probar» (en el panel o con POST /webhooks/{id}/probar). Sirve para comprobar tu servidor. | mensaje |
Forma del aviso
Cada aviso es una petición POST con el evento en JSON:
{
"id": "evt_261019140211a1b2c3d4e5f6",
"tipo": "alerta.creada",
"creado": "2026-10-19T14:02:11Z",
"empresa_rfc": "ABC010101AB1",
"datos": {
"alerta_id": "3f2e8c1a-5b7d-4e90-a1c2-9d4f6b8e0a12",
"severidad": "alta",
"regla": "L-02",
"titulo": "Proveedor en lista 69-B",
"rfc_contraparte": "DEL120301AB3",
"fecha_limite": null
},
"url": "/v1/empresas/ABC010101AB1/alertas?id=3f2e8c1a-5b7d-4e90-a1c2-9d4f6b8e0a12"
}id identifica el evento, creado es cuándo ocurrió y url es la ruta de esta API donde ver el detalle. Los mismos eventos de los últimos 30 días están en GET /eventos, útil para recuperar avisos que tu servidor se perdió.
Encabezados
Sereno-Evento-Id: el id del evento (igual queiden el cuerpo).Sereno-Firma:t=1760882580,v1=<hex>, dondetes la hora en segundos desde 1970 yv1es la firma. Durante la rotación del secreto trae dosv1.
Cómo verificar la firma
La firma es un código HMAC-SHA256 (un código que solo Sereno y tú pueden calcular porque ambos conocen el secreto) de la hora, un punto y el cuerpo exacto: HMAC-SHA256(secreto, t + "." + cuerpo), en hexadecimal. Usa el secreto completo, con su prefijo whsec_, como llave. Para estar seguro de que el aviso viene de Sereno:
- Lee el cuerpo tal como llegó, como texto. Si lo conviertes a objeto y lo vuelves a escribir, la firma ya no coincide.
- Rechaza el aviso si
ttiene más de 5 minutos de diferencia con tu reloj (evita que alguien repita un aviso viejo). - Calcula la firma esperada y compárala con cada
v1con una comparación de tiempo constante (timingSafeEqualen Node,compare_digesten Python), nunca con==.
import crypto from "node:crypto";
// secreto: el whsec_… completo que se mostró al crear el webhook.
// cuerpoCrudo: el cuerpo EXACTO que llegó, como texto (sin volver a serializarlo).
export function verificarFirma(secreto, encabezado, cuerpoCrudo, ahoraS = Math.floor(Date.now() / 1000)) {
const partes = encabezado.split(",").map((p) => p.trim().split("="));
const t = Number(partes.find(([k]) => k === "t")?.[1]);
const firmas = partes.filter(([k]) => k === "v1").map(([, v]) => v);
if (!Number.isFinite(t) || firmas.length === 0) return false;
if (Math.abs(ahoraS - t) > 300) return false; // más de 5 minutos: rechazar
const esperada = crypto.createHmac("sha256", secreto).update(`${t}.${cuerpoCrudo}`).digest("hex");
return firmas.some(
(f) => f.length === esperada.length && crypto.timingSafeEqual(Buffer.from(f), Buffer.from(esperada)),
);
}import express from "express";
import { verificarFirma } from "./verificar-firma.js";
const app = express();
const yaProcesados = new Set(); // en producción, una tabla o Redis con los ids de evento
// Importante: leer el cuerpo SIN convertirlo a objeto antes de verificar la firma.
app.post("/sereno", express.raw({ type: "application/json" }), (req, res) => {
const cuerpo = req.body.toString("utf8");
if (!verificarFirma(process.env.SERENO_SECRETO, req.get("Sereno-Firma") ?? "", cuerpo)) {
return res.sendStatus(400);
}
const evento = JSON.parse(cuerpo);
if (yaProcesados.has(evento.id)) return res.sendStatus(200); // repetido: ya lo atendiste
yaProcesados.add(evento.id);
console.log(evento.tipo, evento.empresa_rfc);
res.sendStatus(200); // responde 2xx en menos de 10 segundos
});
app.listen(3000);import hashlib
import hmac
import time
def verificar_firma(secreto: str, encabezado: str, cuerpo: bytes, tolerancia_s: int = 300) -> bool:
"""secreto: el whsec_… completo. cuerpo: los bytes EXACTOS que llegaron."""
partes = [p.strip().split("=", 1) for p in encabezado.split(",") if "=" in p]
t = next((v for k, v in partes if k == "t"), None)
firmas = [v for k, v in partes if k == "v1"]
if t is None or not t.isdigit() or not firmas:
return False
if abs(time.time() - int(t)) > tolerancia_s: # más de 5 minutos: rechazar
return False
esperada = hmac.new(secreto.encode(), t.encode() + b"." + cuerpo, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(f, esperada) for f in firmas)import os
from flask import Flask, request
from verificar_firma import verificar_firma
app = Flask(__name__)
ya_procesados = set() # en producción, una tabla o Redis con los ids de evento
@app.post("/sereno")
def recibir():
cuerpo = request.get_data() # bytes sin tocar
if not verificar_firma(os.environ["SERENO_SECRETO"], request.headers.get("Sereno-Firma", ""), cuerpo):
return "", 400
evento = request.get_json()
if evento["id"] in ya_procesados:
return "", 200 # repetido: ya lo atendiste
ya_procesados.add(evento["id"])
print(evento["tipo"], evento["empresa_rfc"])
return "", 200 # responde 2xx en menos de 10 segundosEntrega, reintentos y pausa
- Al menos una vez. Un aviso puede llegar repetido: guarda los
idque ya atendiste y descarta los repetidos. No hay garantía de orden; usacreadopara ordenar. - Éxito es una respuesta 2xx en menos de 10 segundos. Responde rápido y procesa después.
- Reintentos si falla: a 1 minuto, 5 minutos, 30 minutos, 2 horas, 6 horas, 24 horas (7 intentos en total). Si todos fallan, la entrega queda como
falliday el evento sigue disponible enGET /eventos. Puedes reintentar a mano desde Cuenta > API. - Pausa. Tras 20 entregas fallidas seguidas, el webhook se pausa y avisamos por correo al administrador. Cuando tu servidor vuelva a responder, reanúdalo desde Cuenta > API.
- Destino. Solo
httpsen el puerto 443, sin usuario ni contraseña y sin redirecciones (una respuesta 3xx cuenta como falla). La dirección se resuelve al enviar y se rechazan las redes privadas, locales y de metadatos, para que nadie use a Sereno contra redes internas. - Rotar el secreto. Al rotarlo, durante 24 horas cada aviso trae dos firmas (la del secreto nuevo y la del anterior). Acepta cualquiera y cambia el secreto en tu sistema con calma.
POST /webhooks/{id}/probar: llega un evento ping firmado igual que los reales.Errores
Cuando algo sale mal, la respuesta trae el código HTTP y un cuerpo con esta forma, siempre con el id_peticion (que también viaja en el encabezado X-Id-Peticion; menciónalo si nos escribes):
{
"error": {
"codigo": "limite_diario",
"mensaje": "Llegaste a 1,000 consultas de listas hoy; se reinicia a las 00:00 (hora de México).",
"id_peticion": "req_8f2c1a9e04",
"reintentar_en": 5400
}
}El mensaje está en español y se puede mostrar tal cual. Programa contra el codigo, que no cambia. reintentar_en (segundos) solo viene en los 429, igual que el encabezado Retry-After.
| Código | HTTP | Cuándo | Qué hacer |
|---|---|---|---|
| parametro_invalido | 400 | Un parámetro, el cuerpo o un RFC no tienen un valor válido. | Corrige la petición; el mensaje dice cuál es el problema. No reintentes igual. |
| llave_invalida | 401 | Falta la llave, no tiene la forma srn_live_…, no existe, está revocada o venció. | Revisa el encabezado Authorization. Todos estos casos dan el mismo 401 a propósito. |
| alcance_insuficiente | 403 | La llave no tiene el alcance que pide la ruta (o usas una ruta de despacho con una llave de cuenta). | Crea una llave con el alcance correcto desde Cuenta > API. |
| plan_sin_api | 403 | El paquete de la cuenta ya no incluye la API. | Cambia de paquete o contrata el complemento. Tus llaves no se borran. |
| cuenta_suspendida | 403 | La cuenta está suspendida por falta de pago o no está disponible para la API. | Regulariza el pago: la API se reactiva sola. Si la cuenta fue cerrada o está en proceso de baja, también verás este código con un mensaje genérico; escríbenos a soporte. |
| no_encontrado | 404 | La ruta o el recurso no existe, o no es de las empresas de tu llave. | Un RFC de otra cuenta responde igual que uno inexistente: así no se revela quién es cliente de Sereno. |
| metodo_no_permitido | 405 | La ruta existe pero no acepta ese método (por ejemplo un POST a /empresas). | Usa el método que indica la referencia. |
| idempotencia_conflicto | 409 | La Idempotency-Key ya se usó con otro cuerpo, o la primera petición con esa clave sigue en curso. | Usa una clave nueva o espera unos segundos. |
| conflicto | 409 | El recurso no está en un estado que permita la acción: por ejemplo, ya tienes el máximo de webhooks de tu plan o el webhook está pausado. | Borra uno, reanuda el webhook desde Cuenta > API y vuelve a intentar. |
| limite_diario | 429 | Llegaste al tope diario de consultas de listas o de lecturas de tu llave. | Espera: el encabezado Retry-After (y reintentar_en) dicen cuántos segundos faltan para las 00:00 de México. |
| limite_minuto | 429 | Pasaste las peticiones por minuto de tu llave, o hubo demasiadas peticiones o llaves inválidas desde tu dirección. | Espera lo que diga Retry-After y baja el ritmo. |
| error_interno | 500 | Algo falló de nuestro lado. | Reintenta con espera creciente. Si sigue, escríbenos con el id_peticion. |
| no_disponible | 503 | El servicio no está disponible un momento. | Reintenta con espera creciente (1, 2, 4… segundos). |
Un RFC de otra cuenta responde exactamente igual que uno que no existe (404 no_encontrado): así la API no revela quién es cliente de Sereno.
Límites por plan
Hay dos cuotas diarias que se reinician a las 00:00, hora de la Ciudad de México: las consultas de listas (cada RFC que revisas cuenta una, también dentro de un lote) y las lecturas (cualquier otra petición cuenta una). Los límites se cuentan por llave. Además hay un tope de peticiones por minuto, que es un freno y puede variar un poco.GET /estado no gasta cuota. Un lote que no cabe completo en lo que te queda se rechaza completo.
| Plan | Consultas de listas al día | Lecturas al día | Peticiones por minuto | Llaves | Webhooks |
|---|---|---|---|---|---|
| Plus con el complemento de APIEl complemento se contrata aparte del paquete Plus. | 1,000 | 5,000 | 60 | 3 | 3 |
| Pro | 10,000 | 50,000 | 120 | 5 | 5 |
| Max | 25,000 | 125,000 | 300 | 10 | 10 |
| CorporativoUso razonable: si te acercas al tope, te contactamos antes de frenar. | 100,000 | 500,000 | 600 | 25 | 50 |
| Integrador (solo listas)Solo funciona el alcance listas. | Según tu paquete | No incluye | 120 | 5 | 0 |
- Un lote lleva hasta 500 RFC por petición.
- Los paquetes que no aparecen en la tabla no incluyen la API.
- Si llegas al límite diario recibes
429 limite_diarioconRetry-After; si pasas el de minuto,429 limite_minuto. Reintenta después de ese tiempo, no antes. - También hay un tope por dirección de internet: 300 peticiones por minuto y 60 intentos con llave inválida en 10 minutos.
- Tu consumo del día está en el panel (Cuenta > API) y en
GET /estado.
Versiones
v1.0.0
- Primera versión pública: 20 endpoints de solo lectura (estado, listas, empresas, documentos, eventos, webhooks y despacho).
- Llaves por cuenta o despacho con alcances (listas, empresas, documentos, webhooks), empresas limitadas y vencimiento opcional.
- Límites diarios y por minuto por llave según el plan; rotación con 7 días de gracia.
- Webhooks firmados con reintentos y entregas consultables.
¿Dudas o algo que no funciona como aquí se dice? Escríbenos a hola@serenofiscal.mx con el id_peticion.