Saltar al contenido

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

  1. Entra a Sereno con una cuenta de administrador y abre Mi cuenta, pestaña API. (En un despacho, la crea el dueño.)
  2. Elige Crear llave, ponle un nombre que la distinga, marca los permisos que necesita y, si quieres, las empresas y una fecha de vencimiento.
  3. 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
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.

Encabezado
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
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:

AlcanceQué permite
listasRevisar RFC en las listas del SAT y OFAC (/listas/…). No necesita empresas, por eso sirve a un integrador.
empresasLeer la ficha de tus empresas, alertas, declaraciones, cumplimiento, facturas, contrapartes, vencimientos y eventos (/empresas/…, /eventos, /despacho/clientes).
documentosPedir enlaces temporales a los PDF (…/descarga).
webhooksCrear, 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, como 2026-10-19; los periodos, como 2026-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}: mientras siguiente_cursor no sea null, pídelo como cursor con los mismos filtros. El cursor es opaco; no lo armes ni lo interpretes.
  • Idempotency-Key. POST /listas/lote y POST /webhooks/{id}/probar aceptan 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 da 409. Se guarda 24 horas.
  • Encabezados de respuesta. X-Id-Peticion (el id que verás también en los errores), X-Limite-Dia y X-Restante-Dia (tu cuota del día), Retry-After en los 429 e Idempotent-Replayed cuando 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étodoRutaAlcanceCuenta contra la cuotaPara qué sirve
get/estadoningunoNo cuentaEstado del servicio, cortes de las listas y, con llave, tu plan y uso de hoy.
get/listas/{rfc}listas1 consulta de listasRevisa un RFC en las listas 69-B, 69, 49 Bis y OFAC.
post/listas/lotelistas1 por RFC válido y distintoRevisa hasta 500 RFC de una vez.
get/empresasempresas1 lecturaEmpresas de la llave con semáforo y alertas.
get/empresas/{rfc}empresas1 lecturaFicha fiscal completa de una empresa.
get/empresas/{rfc}/alertasempresas1 lecturaAlertas de una empresa.
get/empresas/{rfc}/declaracionesempresas1 lecturaDeclaraciones presentadas y periodos faltantes.
get/empresas/{rfc}/cumplimientoempresas1 lecturaOpiniones de cumplimiento y constancias por mes.
get/empresas/{rfc}/cumplimiento/{id}/descargadocumentos1 lecturaEnlace temporal para descargar una opinión o constancia.
get/empresas/{rfc}/declaraciones/{id}/descargadocumentos1 lecturaEnlace temporal para descargar una declaración o su acuse.
get/empresas/{rfc}/cfdi/resumenempresas1 lecturaResumen mensual de facturas e importes.
get/empresas/{rfc}/contrapartesempresas1 lecturaClientes y proveedores que aparecen en listas.
get/empresas/{rfc}/vencimientosempresas1 lecturaVencimientos de la e.firma y alertas con fecha límite.
get/eventosempresas1 lecturaEventos de los últimos 30 días.
post/webhookswebhooks1 lecturaCrea un webhook.
get/webhookswebhooks1 lecturaLista tus webhooks.
delete/webhooks/{id}webhooks1 lecturaBorra un webhook.
post/webhooks/{id}/probarwebhooks1 lecturaManda un aviso de prueba.
get/webhooks/{id}/entregaswebhooks1 lecturaEntregas de un webhook.
get/despacho/clientesempresas1 lecturaClientes 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

EventoCuándo se mandaCampos de «datos»
alerta.creadaSereno abre una alerta en una de tus empresas.alerta_id, severidad, regla, titulo, rfc_contraparte, fecha_limite
alerta.cerradaUna alerta se cierra.alerta_id, severidad, regla, titulo, rfc_contraparte
semaforo.cambiadoEl semáforo de una empresa cambia de color.color_anterior, color_nuevo, motivos
documento.nuevoLlega una opinión de cumplimiento, una constancia o una declaración.tipo, documento_id, mes, sentido, estatus_padron
contraparte.en_listaUn cliente o proveedor de tus empresas aparece en una lista del SAT u OFAC.alerta_id, rfc_contraparte, lista, situacion, relacion, severidad
pingTú 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:

Cuerpo del aviso (alerta.creada)
{
  "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 que id en el cuerpo).
  • Sereno-Firma: t=1760882580,v1=<hex>, donde t es la hora en segundos desde 1970 y v1 es la firma. Durante la rotación del secreto trae dos v1.

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:

  1. Lee el cuerpo tal como llegó, como texto. Si lo conviertes a objeto y lo vuelves a escribir, la firma ya no coincide.
  2. Rechaza el aviso si t tiene más de 5 minutos de diferencia con tu reloj (evita que alguien repita un aviso viejo).
  3. Calcula la firma esperada y compárala con cada v1 con una comparación de tiempo constante (timingSafeEqual en Node, compare_digest en Python), nunca con ==.
JavaScript (Node): verificar-firma.js
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)),
  );
}
JavaScript (Node + Express): recibir el aviso
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);
Python: verificar_firma.py
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)
Python (Flask): recibir el aviso
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 segundos

Entrega, reintentos y pausa

  • Al menos una vez. Un aviso puede llegar repetido: guarda los id que ya atendiste y descarta los repetidos. No hay garantía de orden; usa creado para 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 fallida y el evento sigue disponible en GET /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 https en 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.
Para probar, usa el botón Probar del panel o 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 429
{
  "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ódigoHTTPCuándoQué hacer
parametro_invalido400Un 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_invalida401Falta 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_insuficiente403La 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_api403El paquete de la cuenta ya no incluye la API.Cambia de paquete o contrata el complemento. Tus llaves no se borran.
cuenta_suspendida403La 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_encontrado404La 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_permitido405La ruta existe pero no acepta ese método (por ejemplo un POST a /empresas).Usa el método que indica la referencia.
idempotencia_conflicto409La 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.
conflicto409El 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_diario429Llegaste 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_minuto429Pasaste 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_interno500Algo falló de nuestro lado.Reintenta con espera creciente. Si sigue, escríbenos con el id_peticion.
no_disponible503El 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.

PlanConsultas de listas al díaLecturas al díaPeticiones por minutoLlavesWebhooks
Plus con el complemento de APIEl complemento se contrata aparte del paquete Plus.1,0005,0006033
Pro10,00050,00012055
Max25,000125,0003001010
CorporativoUso razonable: si te acercas al tope, te contactamos antes de frenar.100,000500,0006002550
Integrador (solo listas)Solo funciona el alcance listas.Según tu paqueteNo incluye12050
  • 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_diario con Retry-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.