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.

- 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
- 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
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.
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)"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.
# 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"],
},
}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.
# 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))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.
# 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 problemasReintentar 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.
# 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)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.
# 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()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.