Miércoles, 11 de noviembre de 2026  ·  Español
Intermedio 45 min en total 6 pasos

Cómo extraer datos de facturas con Claude

Convierte facturas en PDF o foto en JSON y CSV validados con la API de Claude, listos para tu contabilidad o ERP.

Daniel Puentes Prias
Consultor de IA · Actualizado el 5 de noviembre de 2026
Imagen de portada editorial · Cómo extraer datos de facturas con Claude · Educación · Claude Builders
Requisitos
  • Python 3.9 o superior instalado
  • Una API key de Anthropic, que se crea desde la consola de desarrolladores
  • Unas cuantas facturas de prueba en PDF, JPG o PNG (mejor si tienes escaneadas y digitales)
  • Conocimientos básicos de Python y JSON
Lo que vas a aprender
  • Enviar PDFs e imágenes de facturas a Claude mediante la API
  • Forzar una salida estructurada con tool use y un esquema JSON adaptado a facturas de España y LATAM
  • Validar automáticamente que bases, impuestos, retenciones y totales cuadran
  • Reintentar la extracción indicando a Claude qué ha fallado
  • Procesar una carpeta entera y exportar un CSV compatible con Excel en español
PASO 1 · ~5 min

Preparar el entorno y la API key

Vamos a trabajar con el SDK oficial de Anthropic para Python. Crea una carpeta para el proyecto, un entorno virtual y, dentro, una subcarpeta llamada facturas donde dejarás los documentos de prueba. Después exporta tu API key como variable de entorno: el SDK la lee automáticamente de ANTHROPIC_API_KEY, así que no hace falta escribirla en el código (y no deberías hacerlo nunca). Si estás en Windows con PowerShell, la sintaxis cambia ligeramente: usa $env:ANTHROPIC_API_KEY="tu-clave". Para comprobar que todo está bien, el último comando hace una llamada mínima al modelo claude-opus-5-5 y debería imprimir una respuesta corta. Si ves un error de autenticación, revisa que la variable esté exportada en la misma terminal en la que ejecutas Python. Mete en la carpeta facturas al menos tres documentos variados: una factura digital en PDF, una escaneada y una foto hecha con el móvil. Así detectarás pronto los casos difíciles en lugar de descubrirlos en producción.

bash
mkdir extractor-facturas && cd extractor-facturas
mkdir facturas
python -m venv .venv
source .venv/bin/activate   # En Windows: .venv\Scripts\activate
pip install anthropic

export ANTHROPIC_API_KEY="sk-ant-..."

python -c "import anthropic; r = anthropic.Anthropic().messages.create(model='claude-opus-5-5', max_tokens=50, messages=[{'role':'user','content':'Di hola'}]); print(r.content[0].text)"
ConsejoSi vas a procesar facturas de clientes reales, añade un archivo .env al .gitignore desde el primer minuto. Las facturas contienen datos personales y en España aplica el RGPD: trabaja con copias anonimizadas mientras desarrollas.
Error comúnNo pegues la API key dentro de los scripts. Es el error que más claves filtra a GitHub.
PASO 2 · ~8 min

Definir el esquema JSON de la factura

La clave para que Claude devuelva siempre el mismo formato es definir una herramienta (tool) con un input_schema y obligarle a usarla. Claude no ejecuta nada: simplemente rellena los argumentos de la herramienta siguiendo tu esquema, y eso es exactamente el JSON estructurado que queremos. El esquema está pensado para facturas de España y Latinoamérica: el campo id_fiscal sirve para NIF/CIF, RFC, CUIT, NIT o RUT, la moneda va en código ISO 4217 y separamos impuestos (IVA 21 %, IVA 16 %, IGV...) de retenciones como el IRPF, muy habituales en facturas de autónomos en España. Permitimos null en los campos que pueden no aparecer: es mejor un null honesto que un valor inventado. Las descripciones de cada campo no son decoración; Claude las lee y las usa como instrucciones, así que sé explícito con formatos de fecha y decimales. Guarda este código como esquema.py.

python
# esquema.py
ENTIDAD = {
    "type": "object",
    "properties": {
        "nombre": {"type": ["string", "null"]},
        "id_fiscal": {
            "type": ["string", "null"],
            "description": "NIF/CIF (España), RFC (México), CUIT (Argentina), NIT (Colombia), RUT (Chile), RUC (Perú)... tal cual aparece",
        },
        "direccion": {"type": ["string", "null"]},
    },
    "required": ["nombre", "id_fiscal"],
}

TOOL_FACTURA = {
    "name": "registrar_factura",
    "description": "Registra los datos extraídos de una factura o ticket.",
    "input_schema": {
        "type": "object",
        "properties": {
            "numero_factura": {"type": ["string", "null"]},
            "fecha_emision": {"type": ["string", "null"], "description": "Formato AAAA-MM-DD"},
            "fecha_vencimiento": {"type": ["string", "null"], "description": "Formato AAAA-MM-DD"},
            "emisor": ENTIDAD,
            "receptor": ENTIDAD,
            "moneda": {"type": "string", "description": "Código ISO 4217: EUR, MXN, COP, ARS, CLP, PEN, USD..."},
            "lineas": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "descripcion": {"type": "string"},
                        "cantidad": {"type": ["number", "null"]},
                        "precio_unitario": {"type": ["number", "null"]},
                        "importe": {"type": ["number", "null"], "description": "Importe de la línea sin impuestos, con descuentos aplicados"},
                    },
                    "required": ["descripcion", "importe"],
                },
            },
            "base_imponible": {"type": ["number", "null"]},
            "impuestos": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "tipo": {"type": "string", "description": "Ej: IVA 21%, IVA 16%, IGV 18%"},
                        "porcentaje": {"type": ["number", "null"]},
                        "importe": {"type": "number"},
                    },
                    "required": ["tipo", "importe"],
                },
            },
            "retenciones": {"type": ["number", "null"], "description": "Total retenido (p. ej. IRPF) en positivo"},
            "total": {"type": ["number", "null"], "description": "Total a pagar"},
        },
        "required": ["numero_factura", "fecha_emision", "emisor", "moneda", "lineas", "base_imponible", "impuestos", "total"],
    },
}
ConsejoAjusta el esquema a lo que realmente necesita tu ERP o tu gestoría. Cada campo extra que no uses es más superficie de error y más tokens de salida.
Error comúnNo marques como required campos que muchas facturas no tienen (como fecha_vencimiento en un ticket). Si lo haces, Claude se verá empujado a rellenarlos con algo plausible en lugar de dejarlos en null.
PASO 3 · ~10 min

Enviar la factura a Claude y forzar la salida estructurada

Ahora la función que hace el trabajo. Lee el archivo, lo codifica en base64 y lo envía como bloque document si es PDF o como bloque image si es una foto. Claude procesa los PDF página a página tanto por el texto como visualmente, así que funciona igual con facturas digitales que con escaneadas. Lo importante está en tool_choice: al indicar {"type": "tool", "name": "registrar_factura"} obligamos a Claude a responder llamando a esa herramienta, con lo que la respuesta siempre es un bloque tool_use cuyo input es un diccionario de Python ya parseado. Nada de buscar llaves en texto libre ni de limpiar comillas. El prompt de sistema fija las reglas que más errores evitan en facturas hispanas: convertir 1.234,56 a 1234.56, fechas ISO y null cuando un dato no está. El parámetro nota lo usaremos en el paso 5 para los reintentos. Guarda el código como extractor.py y pruébalo con una factura.

python
# extractor.py
import base64
import sys
import json
from pathlib import Path

import anthropic
from esquema import TOOL_FACTURA

client = anthropic.Anthropic(max_retries=3)
MODELO = "claude-opus-5-5"

SYSTEM = (
    "Eres un asistente contable experto en facturas de España y Latinoamérica. "
    "Extrae SOLO lo que aparece en el documento; nunca inventes ni deduzcas datos ausentes. "
    "Si un campo no aparece, devuelve null. "
    "Convierte importes a número con punto decimal y sin separador de miles (1.234,56 -> 1234.56). "
    "Fechas en formato AAAA-MM-DD; las fechas españolas y latinoamericanas suelen ir como DD/MM/AAAA. "
    "Las retenciones (IRPF, etc.) van en positivo en el campo retenciones, no como impuesto."
)

TIPOS_IMAGEN = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif"}


def _bloque_archivo(ruta: Path) -> dict:
    datos = base64.standard_b64encode(ruta.read_bytes()).decode("utf-8")
    ext = ruta.suffix.lower()
    if ext == ".pdf":
        return {"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": datos}}
    if ext in TIPOS_IMAGEN:
        return {"type": "image", "source": {"type": "base64", "media_type": TIPOS_IMAGEN[ext], "data": datos}}
    raise ValueError(f"Formato no soportado: {ext}")


def extraer_factura(ruta, nota: str = "") -> dict:
    ruta = Path(ruta)
    instruccion = "Extrae los datos de esta factura."
    if nota:
        instruccion += "\n\n" + nota
    respuesta = client.messages.create(
        model=MODELO,
        max_tokens=4096,
        system=SYSTEM,
        tools=[TOOL_FACTURA],
        tool_choice={"type": "tool", "name": "registrar_factura"},
        messages=[{"role": "user", "content": [_bloque_archivo(ruta), {"type": "text", "text": instruccion}]}],
    )
    for bloque in respuesta.content:
        if bloque.type == "tool_use":
            return bloque.input
    raise ValueError("Claude no devolvió datos estructurados")


if __name__ == "__main__":
    print(json.dumps(extraer_factura(sys.argv[1]), ensure_ascii=False, indent=2))
ConsejoPon el documento antes que el texto de la instrucción en el array content. Con documentos largos, Claude responde mejor cuando la pregunta va después del material.
Error comúnSi una factura de muchas páginas se corta a mitad del JSON, sube max_tokens. Facturas con cientos de líneas generan salidas largas y 4096 tokens puede quedarse corto.
PASO 4 · ~7 min

Validar que los importes cuadran

Un modelo puede leer mal un 8 como un 3 en un escaneo borroso, y eso no lo detecta ningún esquema JSON: el tipo es correcto pero el dato no. Por eso la validación aritmética es la red de seguridad más valiosa del pipeline. La función comprueba tres cosas: que existan los campos imprescindibles, que la suma de líneas coincida con la base imponible y que base + impuestos − retenciones dé el total. Usamos una tolerancia de cinco céntimos para absorber redondeos, que son habituales cuando cada línea se redondea por separado. También verifica que la fecha sea una fecha ISO real. La función no corrige nada: devuelve una lista de problemas en lenguaje natural. Esa decisión es deliberada, porque en el siguiente paso le pasaremos esa misma lista a Claude para que revise el documento. Guarda el código como validacion.py y ejecútalo sobre el resultado del paso anterior para ver qué detecta.

python
# validacion.py
from datetime import date

TOLERANCIA = 0.05


def validar_factura(f: dict) -> list[str]:
    problemas = []

    for campo in ("numero_factura", "fecha_emision", "total"):
        if f.get(campo) in (None, ""):
            problemas.append(f"Falta el campo {campo}")

    base = f.get("base_imponible")
    lineas = f.get("lineas") or []
    if lineas and base is not None:
        suma = round(sum((l.get("importe") or 0) for l in lineas), 2)
        if abs(suma - base) > TOLERANCIA:
            problemas.append(f"Las líneas suman {suma} pero la base imponible es {base}")

    total = f.get("total")
    if base is not None and total is not None:
        impuestos = sum((i.get("importe") or 0) for i in f.get("impuestos") or [])
        retenciones = f.get("retenciones") or 0
        esperado = round(base + impuestos - retenciones, 2)
        if abs(esperado - total) > TOLERANCIA:
            problemas.append(
                f"Base ({base}) + impuestos ({round(impuestos, 2)}) - retenciones ({retenciones}) = {esperado}, pero el total es {total}"
            )

    fecha = f.get("fecha_emision")
    if fecha:
        try:
            date.fromisoformat(fecha)
        except ValueError:
            problemas.append(f"La fecha de emisión '{fecha}' no tiene formato AAAA-MM-DD válido")

    return problemas
ConsejoSi trabajas con facturas mexicanas (CFDI) o colombianas con muchos impuestos y retenciones distintas (ISR, ReteFuente, ReteICA), añade validaciones específicas por país. Son baratas y atrapan muchos errores.
Error comúnLos descuentos globales a pie de factura rompen la comprobación de líneas contra base imponible. Si los tienes, añade un campo descuento_global al esquema y réstalo en la validación en lugar de subir la tolerancia.
PASO 5 · ~6 min

Reintentar la extracción con feedback de los errores

Cuando la validación falla, lo más eficaz no es repetir la misma petición, sino decirle a Claude exactamente qué no cuadra. Con esa pista suele revisar la línea concreta, detectar el dígito mal leído o el impuesto que se le escapó, y devolver una extracción corregida. La función hace como máximo dos reintentos y devuelve los datos junto a la lista de problemas que queden. Si tras los reintentos sigue habiendo discrepancias, no fuerces nada: márcala para revisión humana. En la práctica, con facturas digitales casi nunca llegarás aquí; con fotos de tickets arrugados, sí. Este patrón de extraer, validar y reintentar con feedback es el corazón de cualquier sistema de extracción fiable, y lo podrás reutilizar con albaranes, pedidos o extractos bancarios. Crea procesar.py con este código; en el siguiente paso le añadiremos el procesamiento por lotes.

python
# procesar.py
import csv
import json
from pathlib import Path

from extractor import extraer_factura
from validacion import validar_factura


def extraer_con_reintento(ruta, intentos: int = 2):
    datos = extraer_factura(ruta)
    for _ in range(intentos):
        problemas = validar_factura(datos)
        if not problemas:
            return datos, []
        nota = (
            "En una extracción anterior de este mismo documento se detectaron estas incoherencias: "
            + "; ".join(problemas)
            + ". Revisa el documento con cuidado, en especial los importes y las cifras dudosas, "
            "y devuelve los valores tal como aparecen impresos. Si el documento realmente no cuadra, respeta lo impreso."
        )
        datos = extraer_factura(ruta, nota=nota)
    return datos, validar_factura(datos)
ConsejoLa frase 'si el documento realmente no cuadra, respeta lo impreso' es importante: hay facturas reales con errores de cálculo, y no quieres que Claude ajuste cifras para que la validación pase.
Error comúnNo pongas muchos reintentos. Cada uno vuelve a enviar el documento completo y paga sus tokens de entrada; si dos intentos no lo arreglan, un tercero rara vez lo hace.
PASO 6 · ~9 min

Procesar una carpeta y exportar a CSV para Excel

Último paso: recorrer la carpeta facturas, extraer cada documento, guardar el JSON completo junto al archivo original y generar un CSV resumen con una fila por factura. La columna estado te dice de un vistazo cuáles están OK, cuáles necesitan revisión y cuáles han fallado. Un detalle muy de España y LATAM: Excel configurado en español espera el punto y coma como separador y no reconoce bien los acentos si el CSV no lleva BOM. Por eso escribimos con delimiter=";" y codificación utf-8-sig, y convertimos los decimales a coma. Así el archivo se abre con doble clic y los nombres de emisores con tildes y eñes se ven bien. Los errores de un archivo concreto no detienen el lote: se registran y se pasa al siguiente. Ejecuta python procesar.py y abre facturas.csv.

python
# Añade esto al final de procesar.py

EXTENSIONES = {".pdf", ".png", ".jpg", ".jpeg", ".webp", ".gif"}
CAMPOS = ["archivo", "numero_factura", "fecha_emision", "emisor_nombre", "emisor_id_fiscal",
          "receptor_nombre", "receptor_id_fiscal", "moneda", "base_imponible", "total_impuestos",
          "retenciones", "total", "estado"]


def _num(valor):
    return "" if valor is None else f"{valor:.2f}".replace(".", ",")


def procesar_carpeta(carpeta: str = "facturas", salida: str = "facturas.csv"):
    filas = []
    for ruta in sorted(Path(carpeta).iterdir()):
        if ruta.suffix.lower() not in EXTENSIONES:
            continue
        print(f"Procesando {ruta.name}...")
        try:
            datos, problemas = extraer_con_reintento(ruta)
        except Exception as e:
            filas.append({"archivo": ruta.name, "estado": f"ERROR: {e}"})
            continue

        ruta.with_suffix(".json").write_text(json.dumps(datos, ensure_ascii=False, indent=2), encoding="utf-8")
        emisor = datos.get("emisor") or {}
        receptor = datos.get("receptor") or {}
        impuestos = sum((i.get("importe") or 0) for i in datos.get("impuestos") or [])
        filas.append({
            "archivo": ruta.name,
            "numero_factura": datos.get("numero_factura") or "",
            "fecha_emision": datos.get("fecha_emision") or "",
            "emisor_nombre": emisor.get("nombre") or "",
            "emisor_id_fiscal": emisor.get("id_fiscal") or "",
            "receptor_nombre": receptor.get("nombre") or "",
            "receptor_id_fiscal": receptor.get("id_fiscal") or "",
            "moneda": datos.get("moneda") or "",
            "base_imponible": _num(datos.get("base_imponible")),
            "total_impuestos": _num(impuestos),
            "retenciones": _num(datos.get("retenciones")),
            "total": _num(datos.get("total")),
            "estado": "OK" if not problemas else "REVISAR: " + " | ".join(problemas),
        })

    with open(salida, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=CAMPOS, delimiter=";", restval="")
        writer.writeheader()
        writer.writerows(filas)
    print(f"Listo: {len(filas)} facturas en {salida}")


if __name__ == "__main__":
    procesar_carpeta()
ConsejoSi procesas cientos de facturas al mes y no necesitas el resultado al instante, mira la Message Batches API de Anthropic: procesa peticiones de forma asíncrona con un descuento importante sobre el precio normal.
Error comúnSi vas a importar el CSV en un sistema que espera punto decimal (muchos ERPs y bases de datos), elimina el replace de la función _num. La coma solo tiene sentido para abrirlo directamente en Excel en español.
Resultado

Has montado un extractor de facturas completo con la API de Claude: acepta PDFs digitales, escaneos y fotos, devuelve siempre un JSON con el mismo esquema gracias a tool use forzado, comprueba que bases, impuestos, retenciones y totales cuadran, reintenta indicando a Claude qué ha fallado y deja en un CSV listo para Excel qué facturas están OK y cuáles necesitan revisión humana. El patrón extraer, validar y reintentar sirve igual para albaranes, pedidos o nóminas, y el esquema se adapta en minutos a NIF, RFC, CUIT o NIT según el país. Si quieres profundizar y montar productos completos sobre la API de Claude, con buenas prácticas de costes, errores y despliegue, ese es el siguiente paso natural.

PRÓXIMOS PASOS
TÉRMINOS DE ESTE TUTORIAL