Cómo extraer datos de BBVA México con Python (API de finO$)
Cómo convertir un estado de cuenta de BBVA México (PDF) a JSON con Python usando el API de finO$, con código de ejemplo y el schema de salida.
Procesar un estado de cuenta de BBVA México con Python toma tres llamadas HTTP: subes el PDF a POST /api/v1/statements, consultas el estado hasta que sea completed, y lees los movimientos ya normalizados desde GET /api/v1/transactions. Cuesta $5 MXN por hoja procesada: un estado típico de 8 hojas sale en $40 MXN, y las primeras 30 hojas de cada mes son gratis. Leer los datos después no cuesta nada.
Por qué este tutorial
Si trabajas con datos bancarios mexicanos, eventualmente tienes que parsear PDFs de BBVA. Los estados de cuenta de BBVA México tienen un layout consistente desde 2024 (cuenta de cheques, débito, nómina, empresarial) y son una buena base para construir herramientas contables, dashboards de finanzas personales o motores de underwriting.
Este tutorial muestra cómo llamar al API de finO$ desde Python para convertir un PDF de BBVA en transacciones estructuradas, sin escribir un parser propio y sin pedirle al usuario sus credenciales del banco.
¿Solo necesitas el Excel, sin código? Usa el convertidor para convertir tu estado de cuenta a Excel en el navegador. Tienes 30 hojas gratis cada mes.
Lo que necesitas
- Python 3.9+
- Una cuenta de finO$ y una API key (se crea en la app, en Configuración → API; se muestra una sola vez)
- Un PDF de tu estado de cuenta BBVA México
La llave empieza con fin_sk_ y queda atada al workspace donde la creaste: no necesitas mandar ningún encabezado de workspace.
Setup
pip install requests
export FINOS_API_KEY="fin_sk_..."
Paso 1: sube el PDF
El endpoint acepta multipart/form-data con el campo file y un máximo de 10 MB. Responde 202: el procesamiento es asíncrono, así que todavía no hay transacciones que leer.
import os
import time
import requests
BASE = "https://api.getfinos.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['FINOS_API_KEY']}"}
with open("bbva-statement.pdf", "rb") as f:
upload = requests.post(f"{BASE}/statements", headers=HEADERS, files={"file": f})
# 402 = saldo insuficiente. Es distinto de un fallo: reintentar no ayuda, hay que recargar.
if upload.status_code == 402:
body = upload.json()
faltan = body["required_cents"] - body["available_cents"]
raise RuntimeError(f"Saldo insuficiente: faltan {faltan} centavos")
upload.raise_for_status()
statement_id = upload.json()["data"]["id"]
Ese 402 merece un if propio en tu código. No es un error transitorio: reintentar con backoff no va a cambiar nada, hay que recargar saldo. La respuesta trae required_cents y available_cents para que sepas cuánto falta. Y si tu workspace ya tiene estados de cuenta previos, el archivo no se rechaza: se encola con status: pending y se procesa solo cuando recargas.
Paso 2: espera a que termine
Consulta el estado de cuenta hasta que su status sea completed. Los estados posibles son pending (subido, aún sin procesar), processing, completed y error.
while True:
time.sleep(3)
statement = requests.get(
f"{BASE}/statements/{statement_id}", headers=HEADERS
).json()["data"]
if statement["status"] == "completed":
break
if statement["status"] == "error":
raise RuntimeError("No se pudo procesar el PDF")
print(f"{statement['bank']} · {statement['period_start']} → {statement['period_end']}")
print(f"{statement['page_count']} hojas procesadas")
Los webhooks de notificación están en el roadmap; por ahora, este polling es el patrón. Un estado típico tarda entre 5 y 30 segundos.
Paso 3: lee las transacciones
Ya procesado, los movimientos aparecen en el endpoint de transacciones. Puedes filtrar por banco, cuenta, fecha, categoría o texto libre:
res = requests.get(f"{BASE}/transactions", headers=HEADERS, params={
"bank": "BBVA",
"from": statement["period_start"],
"to": statement["period_end"],
"limit": 200,
}).json()
for tx in res["data"]:
print(f"{tx['date']} | {(tx['concept'] or '')[:40]:40} | {tx['amount']:>10}")
El signo importa: en amount, negativo es salida de dinero y positivo es entrada. No hay un campo aparte de dirección.
Paginar cuando son muchos meses
limit topa en 200 (un valor mayor se acota en vez de fallar) y se pagina con offset. No hagas cuentas con total: el campo has_more te dice si falta pedir más.
def todas_las_transacciones(desde, hasta, banco=None):
salida, offset = [], 0
while True:
params = {"from": desde, "to": hasta, "limit": 200, "offset": offset}
if banco:
params["bank"] = banco
res = requests.get(f"{BASE}/transactions", headers=HEADERS, params=params).json()
salida += res["data"]
if not res["pagination"]["has_more"]:
return salida
offset += res["pagination"]["limit"]
La trampa: validadas vs pendientes
Cada transacción trae un status. Las validated las revisó una persona; las pending salieron de la extracción automática y nadie las ha confirmado todavía.
Esto importa porque GET /api/v1/transactions devuelve ambas, mientras que el resumen mensual (GET /api/v1/summary) y la serie histórica (GET /api/v1/periods) cuentan solo las validadas. Si sumas la lista y la comparas contra el resumen, los números no van a cuadrar. Y no es un bug.
# Si necesitas cifras firmes, filtra:
confirmadas = [t for t in todas_las_transacciones("2026-01-01", "2026-04-30")
if t["status"] == "validated"]
# O deja que el API haga la suma:
resumen = requests.get(f"{BASE}/summary", headers=HEADERS,
params={"month": "2026-04"}).json()["data"]
print(resumen["income"], resumen["expense"], resumen["net_worth"])
Qué campos obtienes
Cada transacción llega ya normalizada, con la misma forma venga de BBVA, Nu o Mercado Pago:
{
"id": "txn_...",
"statement_id": "stmt_...",
"date": "2026-04-03",
"amount": 15000.00,
"currency": "MXN",
"kind": "income",
"status": "validated",
"concept": "TRANSFERENCIA SPEI RECIBIDA - CLIENTE ACME",
"counterparty": "ACME SA DE CV",
"merchant": null,
"category": "client_payment",
"bank": "BBVA",
"account_id": "acc_...",
"account_type": "debit",
"original_amount": null,
"original_currency": null,
"fx_rate": null
}
La lista completa de campos, por objeto, está en la referencia del API.
Manejo de errores
MENSAJES = {
400: "Falta el archivo, está vacío, excede 10 MB o no es PDF",
401: "API key inválida o revocada",
403: "A la llave le falta el scope necesario (subir requiere statements:write)",
404: "No existe, o no pertenece al workspace de tu llave",
402: "Saldo insuficiente: recarga, no reintentes",
429: "Demasiadas peticiones: respeta el header Retry-After",
502: "El procesador de PDFs falló: reintentar puede servir",
}
if not response.ok:
raise RuntimeError(f"{response.status_code}: {MENSAJES.get(response.status_code, 'error desconocido')}")
Los errores devuelven {"error": "...", "code": "..."}. Con un 429, respeta el Retry-After antes de reintentar en vez de disparar de inmediato.
Precio
$5 MXN por hoja procesada, igual en el dashboard y en el API. Un estado de cuenta de BBVA México típicamente tiene 8 hojas, así que cada extracción cuesta $40 MXN. El plan Gratis incluye 30 hojas gratis al mes por workspace (no se acumulan); después pagas solo lo que usas. No hay setup fee, ni mínimo mensual, ni compromiso anual.
Leer los datos ya procesados (transacciones, cuentas, resúmenes) no consume saldo. Solo se cobra el procesamiento de PDFs.
Si procesas 30 estados al mes (caso típico de un contador con 30 clientes):
- 30 estados × 8 hojas = 240 hojas
- 240 hojas − 30 gratis = 210 hojas × $5 MXN = $1,050 MXN/mes
Es menos del costo de una hora de un asistente contable junior. Si tu despacho maneja muchos clientes, el plan Pro te da múltiples workspaces, más hojas incluidas, un precio por hoja más bajo y usuarios ilimitados. Hablemos.
Recursos
- Referencia completa del API
- Spec OpenAPI: genera un cliente en cualquier lenguaje
- Otros bancos soportados en México
- Comparativas de finO$