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.
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.
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:
- Autenticación — cómo tu sistema se identifica ante la API (una API key como Bearer token).
- Sincronización — cómo traes los recibos de cada contrato y recorres su histórico sin traerlo todo de golpe.
- Idempotencia — cómo guardas de forma que reprocesar un periodo no genere filas duplicadas.
- 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:
# API key como Bearer token en el encabezado Authorizationcurl "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 escriturajobs:write. Al crear la key en Credenciales → API elige el preajuste Lectura y escritura, o en Avanzado marcajobs:write. Si tu integración recibe un403 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:
{"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.
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:
totales 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", no84560—; conviértelos con una librería decimal, no con unfloat, si vas a hacer cuentas.
En código, la sincronización completa de un contrato es este bucle:
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.
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.
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.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:
{"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-1para 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— entrue, 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 entruepara 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:
- 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. - Consulta el listado con
period_start_fromigual al último periodo que ya guardaste. Te regresa solo lo nuevo, sin recorrer el histórico.
# 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 periodocurl "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 API | Qué es | Ejemplo de columna en tu esquema |
|---|---|---|
id | Identificador estable del recibo (bil_…) | bill_id (llave primaria / única) |
contract.rpu | Número de contrato CFE (12 dígitos) | rpu |
tariff | Tarifa asignada (GDMTH, GDMTO, PDBT…) | tariff |
period_start / period_end | Inicio y fin del periodo | period_start, period_end (fechas) |
year_month | Periodo en formato AAAA-MM | year_month |
total | Total a pagar, como cadena | total (decimal, no float) |
payment_status | Estado de pago | payment_status |
concepts | Mapa de conceptos medidos → valor | tabla 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/jobspara 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 unidque 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
cursorhastahas_more: falseincluso 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.
- API de CFE: recibos, consumo y tarifas — la referencia de endpoints, autenticación y ejemplos.
- Cómo descargar recibos de CFE con una API — el panorama general de qué te entrega la API.
- API de datos de consumo de CFE — cómo usar el consumo (kWh, demanda, horario) que trae cada recibo.
- Cómo leer el recibo de CFE — qué significa cada concepto que integras.
- Calcula el costo de implementar Batu — estima créditos y plan según tu volumen de recibos y sitios.
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.