Un lector de pasaportes en Python ocupa unas veinte líneas la primera vez y unas cien cuando tiene que sobrevivir en producción. Las ochenta de más no tratan de OCR. Tratan de tres preguntas que plantea toda API de pago: qué pasa cuando la foto es mala, qué pasa cuando una petición agota el tiempo de espera y usted la vuelve a enviar, y cómo saben sus propios registros qué llamadas costaron dinero.

Este tutorial construye ese script con requests y nada más. Usa doc.cheap, una API de OCR para pasaportes y documentos de identidad, y este es el blog de doc.cheap, así que valore las decisiones de producto en consecuencia. Los patrones (una clave de idempotencia por imagen, ramificar según un código de error estable, guardar un indicador de coste por llamada) sirven para cualquier API de pago que llame desde Python.

Una petición con la clave sandbox

La documentación publica una clave sandbox pública, sk_sandbox_public. Ejecuta el mismo reconocimiento que una clave de pago y no requiere cuenta: 10 documentos reconocidos gratis por dirección IP en total, y como máximo 10 peticiones por hora sea cual sea la respuesta. La página de la API gratuita de OCR para pasaportes muestra la misma primera llamada como un único curl. Más adelante, una cuenta añade 100 documentos gratis cada mes, sin tarjeta.

import base64
import requests

with open("specimen.jpg", "rb") as f:
    image = base64.b64encode(f.read()).decode("ascii")

response = requests.post(
    "https://api.doc.cheap/v1/scans",
    headers={"Authorization": "Bearer sk_sandbox_public"},
    json={"image": image},
    timeout=60,
)
scan = response.json()
print(response.status_code, scan["meta"]["status"], scan["meta"]["billed"])

json= establece Content-Type: application/json por usted. La imagen viaja en base64 dentro del cuerpo, en JPEG o PNG. La llamada es síncrona: los campos vuelven en esta misma respuesta, sin id de tarea que consultar ni webhook que alojar.

Pruebe con un espécimen sintético, nunca con su propio pasaporte. Los documentos ficticios "Utopia" de la OACI y las páginas de muestra que publican muchos emisores existen precisamente para esto.

Reduzca la foto primero. Una foto de móvil puede pesar varios megabytes antes de que base64 le añada un tercio. La guía de pasaportes recomienda unos 1600 px en el lado largo con calidad JPEG 85; un cuerpo que supera el límite se rechaza con payload_too_large antes de que se ejecute nada.

Leer la respuesta

Todas las claves de la respuesta están siempre presentes, y un valor desconocido es None tras json(), nunca una clave ausente. Cuatro partes deciden qué hace su código a continuación:

  • meta.status indica si el documento se leyó. Es una de cinco cadenas: recognized, no_document_found, unreadable, unsupported_document, rejected. Solo la primera trae datos.
  • document y holder contienen los valores depurados: document.kind (passport, un documento de identidad, etc.), el país en ISO 3166-1 alfa-3, el número, las fechas en ISO YYYY-MM-DD; holder.surname, holder.given_names, holder.birth_date. Cada grupo es None por completo cuando el escaneo no produjo nada para él.
  • mrz.status es passed, failed o absent: si se encontró la zona de lectura mecánica y si sus dígitos de control coinciden. mrz.text es la zona tal como se leyó, así que puede verificar los dígitos de control usted mismo.
  • meta.billed indica si esta llamada se cargó al saldo.

El punto que da forma a todo el cliente: una foto que no se pudo leer es HTTP 200, no un error. Por eso el código ramifica dos veces: según el código de error HTTP para los rechazos y según meta.status para los resultados. Si lanza una excepción con no_document_found, su bucle de reintentos reenviará una foto que nunca se podrá leer. Si lo trata como un éxito, guardará un documento sin campos.

El indicador billed

Con una clave real, billed es True solo cuando un documento se reconoció de verdad. Nada encontrado, una imagen ilegible, un tipo no admitido, un fallo del lado del servicio: nada de eso se cobra. Un documento cobrado cuesta $0.01, tarifa plana, a cualquier volumen; la comparativa de API de OCR para pasaportes pone ese precio junto a los que publican otros servicios.

El sandbox no cobra nada en absoluto. Con sk_sandbox_public, billed sigue indicando si el mismo escaneo se habría cobrado con una clave real, y eso es lo que hace que valga la pena probar contra él. Guarde el indicador junto a cada resultado: así, sumar sus propias filas con billed de un mes no requiere conciliarlas con ninguna factura.

Reintentos que no pueden cobrar dos veces

El reintento arriesgado es el que sigue a un tiempo de espera agotado. Usted no sabe si la primera petición llegó al servidor, y en una API de pago un reintento a ciegas puede pagar dos veces por una sola imagen.

La solución es una cabecera Idempotency-Key en POST /v1/scans, de 1 a 255 caracteres. Genérela una vez por imagen y envíe el mismo valor en cada intento. Con una clave real, un reintento con la misma clave y el mismo cuerpo recibe el primer resultado en lugar de un segundo reconocimiento, y esa repetición no cuesta nada. Tres normas de la referencia dan forma al código:

  • La clave está ligada al cuerpo. La misma clave con otra imagen u otras opciones da 409 idempotency_conflict. Construya el cuerpo una sola vez, fuera del bucle.
  • Un segundo intento puede llegar mientras el primero aún se ejecuta. Eso es 409 idempotency_in_progress: espere y reintente con la misma clave.
  • Sin resultado guardado no hay repetición. Con retain_hours: 0 no se guarda nada, así que un reintento con la misma clave dentro de las 24 horas recibe 409 idempotency_replay_unavailable en vez de una segunda ejecución. No guardar nada y repetir un resultado no van juntos; elija según el caso de uso.

Las claves sandbox aceptan la cabecera, pero allí no decide nada, porque no se cobra nada. Aun así, merece la pena probar esa ruta del código.

Qué errores merecen un reintento. Todo cuerpo de error tiene la misma forma (code, message, docs_url, request_id, event_id), y es el código lo que debe usar para ramificar, porque un mismo estado HTTP puede llevar códigos que exigen un tratamiento opuesto. La guía gestionar errores los agrupa así:

Códigos Qué hacer
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance Esperar (respetando Retry-After) y reintentar
idempotency_in_progress Esperar y reintentar con la misma clave
validation_failed, invalid_request, payload_too_large, unsupported_media_type Corregir la petición; un reintento vuelve a fallar
unauthorized, registration_required, insufficient_credits Corregir la clave o la cuenta; esperar no cambia nada

Retry-After se expresa en segundos enteros. El límite por hora del sandbox puede pedir casi una hora, y nadie quiere que una llamada a una función duerma tanto, así que el cliente de abajo se rinde cuando la espera supera un minuto.

El cliente completo

# scan.py
import base64
import os
import time
import uuid

import requests

API = "https://api.doc.cheap/v1/scans"
KEY = os.environ.get("DOC_CHEAP_API_KEY", "sk_sandbox_public")
RETRY = {
    "rate_limited", "document_repeated", "internal_error", "engine_unavailable",
    "service_unavailable", "maintenance", "idempotency_in_progress",
}
MAX_WAIT = 60  # segundos; un Retry-After más largo se notifica, no se espera


class ScanError(Exception):
    def __init__(self, status, error):
        super().__init__(f"{error['code']} ({status}): {error['message']}")
        self.code = error["code"]
        self.docs_url = error["docs_url"]
        self.request_id = error["request_id"]


def retry_after(response, attempt):
    try:
        seconds = int(response.headers.get("Retry-After", ""))
    except ValueError:
        seconds = 0
    return seconds if seconds > 0 else 2 ** attempt


def scan(path, reference=None, attempts=4, session=None):
    session = session or requests.Session()
    with open(path, "rb") as f:
        image = base64.b64encode(f.read()).decode("ascii")
    body = {"image": image, "reference": reference, "options": {"return_portrait": False}}
    headers = {
        "Authorization": f"Bearer {KEY}",
        "Idempotency-Key": str(uuid.uuid4()),  # una clave por imagen, reutilizada en cada intento
    }

    for attempt in range(1, attempts + 1):
        last = attempt == attempts
        try:
            response = session.post(API, headers=headers, json=body, timeout=60)
            payload = response.json()
        except (requests.RequestException, ValueError):
            # Un tiempo de espera agotado, una conexión caída o un proxy que responde con HTML.
            if last:
                raise
            time.sleep(2 ** attempt)
            continue

        if response.ok:
            return payload
        error = payload["error"]
        if error["code"] not in RETRY or last:
            raise ScanError(response.status_code, error)
        wait = retry_after(response, attempt)
        if wait > MAX_WAIT:
            raise ScanError(response.status_code, error)
        time.sleep(wait)


def summarise(scan):
    meta, document, holder, mrz = scan["meta"], scan["document"], scan["holder"], scan["mrz"]
    if meta["status"] != "recognized":
        return {"ok": False, "status": meta["status"], "billed": meta["billed"]}
    document, holder = document or {}, holder or {}
    return {
        "ok": True,
        "billed": meta["billed"],
        "kind": document.get("kind"),
        "country": document.get("country"),
        "expiry_date": document.get("expiry_date"),
        "surname": holder.get("surname"),
        "birth_date": holder.get("birth_date"),
        "mrz": mrz["status"],
    }


if __name__ == "__main__":
    import sys

    try:
        print(summarise(scan(sys.argv[1], reference="demo-1")))
    except ScanError as err:
        print(err, err.docs_url, err.request_id, file=sys.stderr)
        sys.exit(1)

Ejecútelo como python scan.py specimen.jpg. Algunas decisiones que conviene explicar:

  • requests no lanza una excepción ante un 4xx o un 5xx salvo que llame a raise_for_status(). El cliente lee el cuerpo JSON en ambos casos, porque el cuerpo de error lleva el código; raise_for_status() lo descartaría.
  • El cuerpo y la clave se construyen una vez, antes del bucle. Eso es lo que hace que cada intento sea la misma petición a ojos del servidor.
  • Una Session reutiliza la conexión entre reintentos y a lo largo de un lote de imágenes. Pase una cuando escanee muchos archivos.
  • return_portrait: False omite el recorte de la foto del titular. Recibe el recorte de la página y los campos, y hay una cara menos en sus logs y en su almacenamiento.
  • reference vuelve como meta.reference (hasta 128 caracteres): la forma sencilla de vincular un escaneo con su propio pedido o usuario. Las dos caras de un documento de identidad son dos llamadas; deles la misma referencia.
  • Nunca registre el cuerpo de la petición. Es un documento de identidad. Registre code, request_id y docs_url; es todo lo que necesita el soporte.

Guardar menos datos

La imagen subida se mantiene en memoria durante la petición y nunca se escribe en almacenamiento persistente. El resultado se conserva para que pueda recuperarlo con GET /v1/scans/{id} durante el plazo que usted elija: por petición, options.retain_hours admite de 0 a 8760, y 0 no guarda nada. Si solo necesita el JSON una vez, envíe retain_hours: 0 y acepte la contrapartida de la repetición descrita arriba. El procesamiento se realiza en la UE.

Pasar a producción

Defina DOC_CHEAP_API_KEY con su propia clave y nada más cambia: el mismo endpoint, la misma forma de respuesta, el mismo cliente. Una clave registrada permite 60 peticiones por minuto, así que un proceso por lotes que respete Retry-After no necesitará su propio limitador de ritmo. Los créditos se compran con criptomonedas (BTC, ETH, TRX o USDT en Ethereum o Tron) desde $1; hoy no hay pago con tarjeta.

Si algo de la respuesta le resulta incómodo de manejar desde Python, escriba a admin@doc.cheap.