API CFE

Cómo integrar los datos de CFE en tu sistema

Guía para integrar los datos de CFE en tu sistema por API: conecta la cuenta una vez, recorre el histórico de recibos página a página y guárdalos sin duplicar.

Por Batu Energy·

Integrar los datos de CFE en tu sistema no es una descarga que corres una vez: es un ciclo que se repite cada mes. Con una API, ese ciclo se reduce a tres movimientos —conectar la cuenta de CFE una sola vez, sincronizar los recibos por pull y guardarlos sin duplicar—, y tu sistema consume JSON ya normalizado en vez de PDFs. Este es el flujo que resuelve la API de Batu: tú programas contra un contrato estable, y la parte tediosa —mantener viva la conexión con CFE y normalizar cada recibo— la hace la API.

Esta guía es para quien va a construir la integración: cómo se recorre el histórico de recibos página por página, cómo guardarlos de forma idempotente para no duplicar, cómo pedir recolecciones bajo demanda con trabajos asíncronos, y cómo mapear los conceptos de CFE a tu propio esquema.

Integrar CFE es un ciclo: conecta una vez, sincroniza por pullrepite cada cierre de facturaciónTu sistemaERP · facturación · reportesdetección de anomalíasAPI de Batudescarga y normalizamantiene la conexión con CFEGET /utility/billsrecibos en JSONupsert por idTu base de datos1Conecta una vez2Sincroniza por pull3Guarda sin duplicar
La integración no es una descarga única: es un ciclo. Conectas la cuenta de CFE una vez; a partir de ahí, cada cierre de facturación tu sistema pide los recibos por API, los recibe en JSON ya normalizado y los guarda haciendo upsert por el identificador estable de cada recibo. Diagrama esquemático; las rutas corresponden a la API pública v1.

Si aún no viste el panorama general —qué te entrega exactamente una API de recibos de CFE— empieza por Cómo descargar recibos de CFE con una API. Aquí damos por hecho ese "qué" y nos concentramos en el "cómo integrarlo".


Qué significa integrar los datos de CFE en tu sistema

Integrar no es "bajar un recibo": es mantener tu base de datos al día con lo que CFE factura, de forma automática y confiable. Una integración de CFE bien hecha resuelve cuatro cosas:

  1. Autenticación — cómo tu sistema se identifica ante la API (una API key como Bearer token).
  2. Sincronización — cómo traes los recibos de cada contrato y recorres su histórico sin traerlo todo de golpe.
  3. Idempotencia — cómo guardas de forma que reprocesar un periodo no genere filas duplicadas.
  4. Recolección — cómo pides datos que aún no existen del lado de la API, sabiendo que visitar CFE tarda.

El dato de CFE vive atrapado en un formato pensado para leerse con los ojos: un PDF mensual y, en las tarifas de demanda, un XML asociado. La integración convierte eso en un flujo de datos: cada recibo llega como un objeto con llaves estables, y tu sistema lo consume igual que consume cualquier otra fuente. Lo que cambia respecto a capturar números a mano es que el flujo es repetible y programable.


El patrón: conecta una vez, sincroniza por pull

El modelo es el de una API de datos, no el de un portal. Se resume en dos fases:

  • Conectas la cuenta de CFE una sola vez. Autorizas tus credenciales; a partir de ahí la API mantiene esa conexión viva y descarga los recibos de cada RPU —el número de 12 dígitos que identifica tu contrato con CFE— por ti.
  • Sincronizas por pull. En cada cierre de facturación, tu sistema pide los recibos, recibe JSON normalizado y lo guarda. Batu no manda push por webhook hoy; más abajo está el patrón para que aun así te enteres de cada recibo nuevo sin recorrer el histórico.

Todo el ejemplo de esta guía asume que ya tienes tu API key. La API de Batu vive en app.batuenergy.com/api/v1. Los recursos de CFE cuelgan de /utility/utility/bills, /utility/jobs—, que es la ruta que documenta el portal de desarrolladores; las formas cortas /bills y /jobs siguen respondiendo igual. Te autenticas mandando la key como Bearer token en el encabezado Authorization:

Autenticación
# API key como Bearer token en el encabezado Authorization
curl "https://app.batuenergy.com/api/v1/utility/bills?rpu=TU_RPU" \
-H "Authorization: Bearer TU_API_KEY"

Permisos de la key. Una API key nueva nace solo de lectura: con ella puedes listar y leer recibos, pero pedir una recolección (POST /utility/jobs) exige el permiso de escritura jobs:write. Al crear la key en Credenciales → API elige el preajuste Lectura y escritura, o en Avanzado marca jobs:write. Si tu integración recibe un 403 insufficient_scope, es esto.

A partir de aquí, integrar es responder tres preguntas: cómo recorro todos los recibos, cómo los guardo sin duplicar, y cómo pido los que aún no tengo.


Sincroniza los recibos: recorre el histórico página por página

La sincronización de un contrato es una petición autenticada a la lista de recibos, filtrada por RPU. La respuesta es un sobre de lista con los recibos y los datos para pedir la siguiente página:

GET/api/v1/utility/bills?rpu=123456789012
200 OK
{
"status": "success",
"items": 1,
"total": 124,
"has_more": true,
"next_cursor": "eyJpZCI6ImJpbF8wMUpRMks…",
"data": [
{
"id": "bil_01JQ2K7R8MW3XZ",
"object": "bill",
"currency": "MXN",
"total": "84560.00",
"year_month": "2026-05",
"period_start": "2026-04-01",
"period_end": "2026-04-30",
"tariff": "GDMTH",
"payment_status": "paid",
"contract": {
"rpu": "123456789012",
"pricing_zone": "Jalisco",
"is_monitored": true
},
"concepts": {
"kwh": "12450",
"demandaMaxima": "48.20",
"factorPotencia": "94.2"
}
}
],
"concept_meaning": {
"kwh": { "label": "kWh Totales", "units": "kWh" },
"demandaMaxima": { "label": "Demanda Máxima", "units": "kW" },
"factorPotencia": { "label": "Factor de Potencia", "units": "%" }
}
}

Los tres campos que gobiernan la paginación son items (cuántos recibos trae esta página), has_more (si quedan más) y next_cursor (la marca para pedir la siguiente). El patrón de integración es un bucle: mientras has_more sea verdadero, vuelves a pedir pasando el next_cursor anterior como parámetro cursor.

Recorre el histórico página por páginaCada respuesta trae next_cursor; lo pasas de vuelta hasta que has_more es falso. Ejemplo ilustrativo.Página 1items: 100has_more: truenext_cursor: eyJ…7QkPágina 2items: 100has_more: truenext_cursor: eyJ…Lp2Página 3items: 24has_more: falsenext_cursor: nullcursorcursorfinhistóricocompletoUn bucle simple: mientras has_more sea verdadero, pide la siguiente página con el next_cursor anterior.
Para un portafolio con mucho histórico, la respuesta se pagina. Recorres todos los recibos de un contrato sin traerlos de golpe: pasas el next_cursor de cada respuesta como cursor de la siguiente, hasta que has_more es falso. Valores ilustrativos; el sobre de lista corresponde a la API pública v1.

Así recorres todo el histórico de un contrato sin traerlo de golpe. Un par de detalles que ahorran sorpresas al programar contra esto:

  • total es el tamaño de todo el conjunto que coincide, no lo que falta. No lo uses para saber si terminaste; para eso está has_more.
  • Los valores numéricos viajan como cadenas de texto. Así conservan la precisión decimal exacta del recibo —"84560.00", no 84560—; conviértelos con una librería decimal, no con un float, si vas a hacer cuentas.

En código, la sincronización completa de un contrato es este bucle:

Sincronizar un contrato (página por página)
const BASE = "https://app.batuenergy.com/api/v1";
const headers = { Authorization: `Bearer ${process.env.BATU_API_KEY}` };
async function syncContract(rpu) {
let cursor = null;
do {
const url = new URL(`${BASE}/bills`);
url.searchParams.set("rpu", rpu);
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, { headers });
const page = await res.json();
for (const bill of page.data) {
await upsertBill(bill); // guarda por id — ver la siguiente sección
}
cursor = page.has_more ? page.next_cursor : null;
} while (cursor);
}

Para varios contratos, envuelves esto en un recorrido por tus RPUs. La llamada y la paginación son idénticas para uno o para cientos. Los valores del ejemplo son ilustrativos; los límites de uso y el modelo de créditos dependen de tu plan de acceso.


Guarda sin duplicar: identificadores estables e idempotencia

Aquí es donde una integración se rompe o se sostiene. Una sincronización vuelve a leer periodos que ya tenías —es lo normal y lo deseable, porque un recibo puede actualizarse (un pago que se registra, una corrección de CFE)—. Si guardas con insert a ciegas, cada sincronización duplica los recibos que ya tenías y tu histórico deja de ser confiable.

La solución es la idempotencia: cada recibo llega con un identificador estable —el campo id, con prefijo bil_— que no cambia entre sincronizaciones. Guardas con upsert usando ese id como llave, y reprocesar un periodo actualiza la fila en vez de crear otra.

El mismo recibo dos veces = una sola filaEl id del recibo es estable, así que puedes guardar con upsert sin duplicar.Sync de abrilbil_01JQ2K7R8MW3XZSync de mayo (re-lee abril)bil_01JQ2K7R8MW3XZupsertpor id1 filaabril, actualizadaSin id estable (insert a ciegas):2 filas de abrilel recibo se duplica en tu histórico.
Una sincronización vuelve a leer periodos que ya tenías —es lo normal y lo deseable—. Si guardas con upsert usando el id estable del recibo como llave, el reproceso actualiza la fila en vez de duplicarla. Identificadores ilustrativos y anonimizados; el prefijo bil_ corresponde al identificador de recibo de la API pública v1.

La regla práctica: el id del recibo es tu llave primaria, o al menos una restricción de unicidad. No inventes tu propia llave a partir de RPU + periodo; usa el id que ya te da la API. En SQL, eso es un INSERT … ON CONFLICT (id) DO UPDATE; en un ORM, el método upsert equivalente. Con eso, puedes correr la sincronización tantas veces como quieras —cada hora, cada día, tras una caída— sin miedo a ensuciar los datos.

Este es también el motivo por el que conviene guardar el recibo completo aunque hoy solo uses tres campos: cuando mañana necesites el desglose por horario o el factor de potencia, ya lo tienes, sin re-sincronizar el histórico.


Recolección bajo demanda: trabajos asíncronos

El listado te da lo que la API ya tiene. ¿Y cuando necesitas un recibo que aún no está —un RPU nuevo, o periodos históricos que nunca se han recolectado? Ahí entra la recolección bajo demanda.

Como visitar CFE es un proceso real que tarda algunos minutos, la recolección no puede ser una respuesta inmediata: es asíncrona. Haces POST a la ruta de trabajos con el RPU, y en vez de esperar, recibes un trabajo con su propio identificador (prefijo cfj_) y su estado. Después consultas ese trabajo hasta que termina.

Recolección bajo demanda: un trabajo que consultas hasta que terminaPOST /utility/jobs→ crea el trabajoid: cfj_01JQ…status: queuedqueuedcollectingprocessingpersistingfinalizingcompletedGET /utility/jobs/:id (poll)recibos listos enGET /utility/bills¿failed? Reintenta conPOST /utility/jobs/:id/retry
Pedir una recolección a CFE no es instantáneo (es un proceso real que tarda). Por eso es asíncrono: POST /api/v1/utility/jobs te devuelve un trabajo con su id (prefijo cfj_), que consultas con GET /api/v1/utility/jobs/:id hasta que su status queda en completed. Las fases corresponden a la API pública v1; valores ilustrativos.
Pedir una recolección
curl -X POST "https://app.batuenergy.com/api/v1/utility/jobs" \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "rpu": "123456789012", "period_count": 12 }'

El trabajo que te regresa trae su id, su status y su phase, además de un bloque progress que va contando los periodos conforme avanza:

POST/api/v1/utility/jobs
201 Created
{
"status": "success",
"data": {
"id": "cfj_01JQ2M8T4C6XZ9",
"object": "job",
"rpu": "123456789012",
"type": "bill_collection",
"status": "queued",
"phase": "queued",
"progress": {
"periods_discovered": null,
"periods_collected": 0,
"periods_processed": 0,
"periods_persisted": 0,
"periods_failed": 0
},
"result": null,
"error": null,
"created_at": "2026-09-07T17:04:12Z"
}
}

A partir de ahí, consultas GET /api/v1/utility/jobs/{id} cada cierto tiempo hasta que el status deja de ser queued o running y queda en completed (o partial_success, failed o cancelled). Cuando termina, los recibos nuevos aparecen en el listado normal —GET /api/v1/utility/bills— y los guardas con el mismo upsert por id de la sección anterior.

Un par de campos del cuerpo que vale la pena conocer al integrar:

  • period_count — cuántos periodos de facturación recolectar: un entero de 1 a 48, o -1 para todo el histórico disponible.
  • service_name — el nombre del servicio tal como aparece en el recibo; solo es obligatorio cuando el RPU es nuevo para tu organización.
  • monitor — en true, Batu vuelve a CFE por ese RPU en cada cierre de facturación sin que se lo pidas. Es la mitad del patrón de la siguiente sección.
  • force_refresh — por defecto la API sirve de caché cuando ya hay datos recientes; ponlo en true para forzar una recolección en vivo (más lenta, porque va a CFE).

Para portafolios grandes, existe una variante en lote que encola los trabajos de muchos RPUs en una sola petición, en vez de una llamada por contrato.


¿Y si quiero enterarme cuando haya un recibo nuevo?

Es la pregunta que hace todo integrador, y la respuesta honesta tiene dos partes.

Hoy no hay webhooks. Batu no manda una petición a tu servidor cuando aparece un recibo; la entrega por webhook está en el plan, pero no existe una ruta para registrar tu URL. Cuando exista, esta guía se actualiza.

Lo que sí hay es un push simulado con dos llamadas, y en la práctica resuelve lo mismo:

  1. Al crear el trabajo de recolección, manda "monitor": true. A partir de ahí Batu vuelve a CFE por ese RPU en cada cierre, sin que tu sistema lo pida.
  2. Consulta el listado con period_start_from igual al último periodo que ya guardaste. Te regresa solo lo nuevo, sin recorrer el histórico.
Recibos nuevos desde el último periodo conocido
# 1) Recolección recurrente para el RPU (una sola vez)
curl -X POST "https://app.batuenergy.com/api/v1/utility/jobs" \
-H "Authorization: Bearer TU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "rpu": "123456789012", "period_count": 12, "monitor": true }'
# 2) Cada día o cada semana: solo lo que empezó después de tu último periodo
curl "https://app.batuenergy.com/api/v1/utility/bills?rpu=123456789012&period_start_from=2026-08-01" \
-H "Authorization: Bearer TU_API_KEY"

Como guardas por id de forma idempotente, sondear con un poco de traslape es inofensivo: si vuelves a pedir un periodo que ya tenías, tu base actualiza la fila en vez de duplicarla. Un sondeo diario es más que suficiente; CFE emite un recibo por periodo, no por hora.

Mapea los conceptos de CFE a tu esquema

Con los recibos entrando de forma idempotente, la última pieza es traducir su forma a tu modelo de datos. La respuesta separa dos cosas: los datos del contrato y del periodo (campos de primer nivel, iguales para toda tarifa) y los conceptos medidos, que viven en un mapa abierto.

Campo de la APIQué esEjemplo de columna en tu esquema
idIdentificador estable del recibo (bil_…)bill_id (llave primaria / única)
contract.rpuNúmero de contrato CFE (12 dígitos)rpu
tariffTarifa asignada (GDMTH, GDMTO, PDBT…)tariff
period_start / period_endInicio y fin del periodoperiod_start, period_end (fechas)
year_monthPeriodo en formato AAAA-MMyear_month
totalTotal a pagar, como cadenatotal (decimal, no float)
payment_statusEstado de pagopayment_status
conceptsMapa de conceptos medidos → valortabla hija bill_concepts

Los conceptos —consumo en kWh, demanda en kW, factor de potencia— llegan en un mapa abierto (concepts) porque qué conceptos trae un recibo depende de su tarifa: un recibo GDMTH desglosa la energía por horario (base, intermedia y punta) y cobra demanda; uno de baja tensión sin demanda, no. Por eso el mapa siempre viene acompañado de una leyenda, concept_meaning, que te dice el nombre legible y las unidades de cada llave.

La consecuencia para tu esquema: no modeles los conceptos como columnas fijas (kwh, demanda, fp), porque el conjunto cambia entre tarifas y divisiones. Modélalos como una tabla hija de pares llave-valor (bill_id, concept, value, unit), y usa concept_meaning para poblar las unidades. Así tu integración no se rompe cuando aparece un contrato de otra tarifa. Si necesitas el significado de cada concepto —qué es capacidad, qué es distribución, cómo se calcula el factor de potencia—, lo cubrimos en Cómo leer el recibo de CFE.


Mantén la integración sana: errores, reintentos y límites

Una integración vive en producción, así que conviene programarla para el día en que algo falle:

  • Reintenta con seguridad. Repetir un POST /api/v1/utility/jobs para un RPU que ya tiene un trabajo activo del mismo tipo no crea un duplicado: te devuelve el trabajo existente. Eso hace que reintentar sea seguro por diseño; siempre recibes un id que puedes consultar.
  • Un trabajo fallido se reintenta explícitamente. Si un trabajo termina en failed, hay una ruta de reintento (POST /api/v1/utility/jobs/{id}/retry) que revive ese mismo trabajo en vez de crear otro. Distingue "reintentar tracking" (re-POST) de "reintentar una falla asentada" (retry).
  • Respeta la paginación siempre. No asumas que un contrato cabe en una página; recorre el cursor hasta has_more: false incluso si hoy tus contratos tienen pocos recibos.
  • Guarda de forma idempotente, punto. Es la red de seguridad que hace que una sincronización a medias, o corrida dos veces, no ensucie tus datos.

Sobre límites de uso: las peticiones están sujetas a límites por tu plan, y la recolección en vivo consume créditos. Diséñalo para sincronizar por lotes en tus horarios, no en un bucle apretado, y guarda lo que ya tienes para no volver a pedirlo.


API o construir tu propia integración con el portal de CFE

Es tentador resolver esto con un script que entre al portal, baje los PDFs y los meta a tu base de datos. Funciona en la demo; el problema aparece después. El portal de CFE no es una API: no tiene contrato estable, ni versiones, ni garantías, y cambia sin avisar. Cuando cambia —el login, un formato, un captcha—, tu integración se rompe justo en el cierre de facturación, que es cuando más la necesitas.

  • Mantenimiento constante. Cada cambio del lado de CFE te obliga a parchar y volver a probar; puede ser cada mes.
  • Casos borde interminables. Distintas tarifas, formatos de RPU, lecturas estimadas, PDFs y XMLs que no siempre cuadran.
  • Costo real oculto. El tiempo de ingeniería y las fallas en producción terminan costando más que consumir una API ya mantenida.

Ese análisis lo desarrollamos a fondo en el artículo sobre descargar recibos con una API. La diferencia de fondo: una API te da un contrato estable contra el cual programar —rutas, formatos y garantías que no cambian bajo tus pies—, y traslada el mantenimiento de la conexión con CFE a quien la opera.


Cómo lo resuelve Batu

Batu ya descarga recibos de CFE a escala y expone esos datos por API con el patrón de esta guía: conectas la cuenta una vez, sincronizas por pull recorriendo el histórico página por página, guardas por id de forma idempotente y pides recolecciones bajo demanda con trabajos asíncronos. Tú tomas la batuta y construyes encima —facturación, reportes de ahorro, detección de anomalías—, mientras nosotros nos encargamos de la parte tediosa de obtener y normalizar los datos de CFE. El acceso a la API es para cuentas autenticadas; puedes solicitar acceso o ver una demo.

Si en vez de construir sobre la API lo que quieres es gestionar el consumo de tu propia empresa multi-sitio —descargar los recibos de todas tus sucursales, monitorear pagos y detectar anomalías—, eso lo cubre la plataforma de Batu directamente, sin escribir código.


Fuentes

  • ACUERDO CT/11.SE/8-2025 — Comisión Nacional de Energía (CNE), publicado en el DOF el 23 de enero de 2026. Estructura tarifaria de CFE (tarifas comerciales e industriales, tensiones y bloques horarios) que determina qué conceptos trae cada recibo. La CNE absorbió a la Comisión Reguladora de Energía (CRE) en la reforma energética de 2025; los acuerdos previos de la CRE siguen vigentes bajo la CNE.
  • Acuerdo A/158/2024 (DOF, 24 de enero de 2025) y Código de Red RES/550/2021 — factor de potencia y umbrales aplicables, uno de los conceptos que entrega la API.
  • API pública v1 de Batu — base URL, autenticación por Bearer token, el sobre de lista paginado (GET /api/v1/utility/bills) y los trabajos de recolección (POST /api/v1/utility/jobs, GET /api/v1/utility/jobs/:id). Los valores mostrados en los ejemplos son ilustrativos.

Preguntas frecuentes

¿Cómo se integran los datos de CFE en un sistema propio?

Con una API. Conectas la cuenta de CFE una sola vez y, a partir de ahí, tu sistema sincroniza los recibos por pull: pide la lista de recibos de cada contrato (RPU), recorre el histórico página por página y guarda cada recibo en tu base de datos haciendo upsert por su identificador estable, para no duplicar. Batu mantiene la conexión con CFE detrás de la API; tú solo consumes JSON normalizado. Es el patrón que resuelve la API de Batu.

¿Qué es mejor para integrar CFE: una API o un scraper del portal?

Una API. El portal de CFE no es una API: no tiene contrato estable, ni versiones, ni garantías, y cambia sin avisar (login, formatos, captchas). Un scraper propio se rompe justo en el cierre de facturación y su mantenimiento es una tarea permanente. Una API ya mantenida traslada ese costo a quien la opera y te entrega un contrato estable contra el cual programar tu integración.

¿Cómo evito recibos duplicados al sincronizar?

Guardando con upsert usando el identificador estable de cada recibo como llave, no con insert a ciegas. Cada recibo llega con un id propio (por ejemplo, con prefijo bil_) que no cambia entre sincronizaciones, así que cuando una sincronización vuelve a leer un periodo que ya tenías, tu base de datos actualiza esa fila en vez de crear otra. Reprocesar periodos es normal y deseable; la idempotencia por id es lo que evita que ensucie tu histórico.

¿Cómo pido a CFE recibos que aún no tengo?

Con un trabajo de recolección bajo demanda. Como visitar CFE tarda, la recolección es asíncrona: haces POST a la ruta de trabajos con el RPU, recibes un trabajo con su propio id (prefijo cfj_) y su estado, y lo consultas con GET al trabajo hasta que su status queda en completed. Cuando termina, los recibos nuevos aparecen en el listado normal. Repetir el POST para un RPU con un trabajo activo es seguro: te devuelve el trabajo existente, no crea uno duplicado.

¿Batu puede avisarme cuando un RPU tenga un recibo nuevo?

Hoy no por webhook: la entrega por webhook está planeada pero no construida, y no hay forma de registrar una URL. Lo que sí existe es un push simulado con dos piezas. Al crear el trabajo de recolección mandas monitor en true, y Batu vuelve a CFE por ese RPU en cada cierre sin que se lo pidas. Después, tu sistema consulta GET /utility/bills con el filtro period_start_from igual al último periodo que ya tienes, y recibe solo lo nuevo. Como guardas por id de forma idempotente, sondear de más nunca duplica nada.

¿La misma integración sirve para muchos contratos?

Sí. Una vez conectada la cuenta, la sincronización es la misma para uno o para cientos de RPUs: recorres cada contrato con la misma llamada, página por página. Para portafolios grandes, la recolección se puede pedir en lote en una sola petición. Los límites de uso y el modelo de créditos dependen de tu plan de acceso.

¿Listo para automatizar tu gestión de energía?

Batu descarga tus recibos CFE automáticamente, monitorea tus instalaciones solares y genera reportes de ahorro. Sin esfuerzo.