Constaia
Integraciones

Python

Integra Constaia en Python con el SDK oficial constaia, síncrono y async, con reintentos, errores tipados, webhooks verificados y tests con pytest.

Esta guía cubre la integración en Python con el SDK oficial constaia: analizar un fichero, una URL o base64, tratar errores, controlar reintentos y concurrencia, verificar webhooks y probarlo todo con una clave ck_test_. Al final tienes la alternativa sin SDK con httpx o requests. Para frameworks, mira Django, Flask, FastAPI y Celery; la referencia completa del SDK está en SDK de Python.

Requisitos

  • Python ≥ 3.9 (algunos ejemplos usan match, de Python 3.10). El SDK solo depende de httpx.
  • Una clave de test ck_test_… del panel (API keys). En modo test no se consumen créditos y la respuesta depende del nombre del fichero: ver Modo test.
  • La clave vive solo en tu servidor. Nunca la envíes a un navegador ni a una app móvil.

Instalación

pip install constaia

Variables de entorno

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

Constaia() lee CONSTAIA_API_KEY si no le pasas api_key. CONSTAIA_WEBHOOK_SECRET es el secreto whsec_… que devuelve la creación del endpoint de webhook (en el panel o con client.webhook_endpoints.create(...)); solo se muestra una vez.

Primera llamada

Tú decides en el servidor qué documento esperas (expect) y qué comprobaciones aplicar (checks).

first_call.py
from constaia import Constaia

client = Constaia()  # lee CONSTAIA_API_KEY

analysis = client.analyze(
    "dni_valid.jpg",
    expect="es_dni",
    checks={"not_expired": True},
    storage="none",
    language="es",
)

print(analysis["verdict"]["status"])                    # valid
print(analysis["fields"]["document_number"]["value"])   # 12345678Z
for reason in analysis["verdict"]["reasons"]:
    print(reason["code"], reason["severity"], reason["message"])

Con la clave de test y un JPEG real llamado dni_valid.jpg, la respuesta (resumida) es:

{
  "id": "an_01J...",
  "object": "analysis",
  "status": "completed",
  "livemode": false,
  "document": { "type": "es_dni", "label": "DNI (España)", "confidence": 0.97, "side": "both", "country": "ESP" },
  "verdict": {
    "expected": ["es_dni"],
    "match": true,
    "status": "valid",
    "reasons": [
      { "code": "type_match", "severity": "info", "message": "El documento es DNI (España)." },
      { "code": "not_expired", "severity": "info", "message": "Vigente hasta el 12/03/2031." }
    ]
  },
  "fields": {
    "document_number": { "value": "12345678Z", "confidence": 0.99, "validated": true, "source": { "page": 1, "bbox": [0.61, 0.12, 0.83, 0.16] } }
  },
  "checks": [
    { "code": "nif_check_digit", "passed": true, "message": "La letra del documento 12345678Z es correcta." }
  ],
  "warnings": [],
  "usage": { "credits": 0, "pages": 1 }
}

Las respuestas son dict normales con la misma forma que el JSON de la API, tipadas con TypedDict (constaia.types.Analysis, Classification, Batch…), así que mypy y pyright comprueban los nombres de campo. verdict es None si no pasas expect. Decide con verdict["status"] (valid, invalid o review), usa reasons[].code (estable) para tu lógica y reasons[].message (traducido según language) para el usuario. Más en Veredictos y Comprobaciones.

decide.py
from constaia.types import Analysis


def decide(analysis: Analysis) -> str:
    verdict = analysis["verdict"]
    if verdict is None:
        return "sin veredicto"
    if verdict["status"] == "valid":
        return "aceptado"
    if verdict["status"] == "review":
        return "revisión manual"
    errors = [r["message"] for r in verdict["reasons"] if r["severity"] == "error"]
    return "rechazado: " + "; ".join(errors)

Entradas

analyze() y classify() aceptan exactamente una de estas entradas:

EntradaEjemplo
Ruta (str o pathlib.Path)client.analyze("scans/dni.jpg")
bytesclient.analyze(data, filename="dni.jpg")
Fichero binario abiertoclient.analyze(open("dni.pdf", "rb")), UploadedFile de Django, FileStorage.stream de Flask
Tupla (filename, bytes)client.analyze(("dni.jpg", data))
URL remotaclient.analyze(file_url="https://…/dni.jpg")
Base64client.analyze(file_base64=b64, filename="dni.jpg")

Pasa siempre el nombre original con filename= cuando no vaya implícito: en modo test decide la respuesta. El fichero se lee una vez en memoria para que los reintentos reenvíen los mismos bytes. file_url debe ser https, sin IPs privadas, de 20 MB como máximo y descargable en 15 s.

inputs.py
from constaia import Constaia

client = Constaia()

receipt = client.analyze(
    file_url="https://files.example.com/receipts/payment_receipt.pdf",
    expect="payment_receipt",
    checks={
        "expected_amount": 45,
        "expected_iban": "ES7921000813610123456789",
        "expected_reference": "INSCRIPCION 123",
    },
)

with open("invoice.pdf", "rb") as fh:
    invoice = client.analyze(fh, expect="invoice", export=["xlsx"], metadata={"supplier": "acme"})
print(invoice["exports"].get("xlsx"))  # URL firmada, válida 24 h

kind = client.classify("unknown.pdf", expect=["invoice", "payment_receipt"])  # 0,2 créditos
print(kind["document"], kind["candidates"])

Las opciones usan los nombres de la API (expect, extract, checks, storage, ttl_hours, keep_results, export, metadata, language). Como async es palabra reservada en Python, la opción se llama async_. Todas las opciones en POST /v1/analyze.

Errores

Todas las excepciones heredan de constaia.ConstaiaError y exponen message, status, type, code, param y request_id.

ExcepciónHTTPQué hacer
InvalidRequestError400, 409, 413, 415, 422Mira code (file_too_large, unsupported_file_type, unreadable_image, too_many_pages, invalid_parameter con param…)
AuthenticationError401Revisa CONSTAIA_API_KEY
InsufficientCreditsError402Recarga créditos en el panel; avisa a tu equipo
PermissionDeniedError403La clave no puede hacer esa acción
NotFoundError404Análisis borrado, de otra cuenta o creado con keep_results=False
RateLimitError429Ya reintentado por el SDK; retry_after indica cuánto esperar
APIError5xxYa reintentado; 503 live_mode_unavailable indica que el modo live no está disponible: usa ck_test_ mientras tanto
APIConnectionError, APITimeoutError—Red o timeout tras los reintentos
WebhookVerificationError—Firma de webhook no válida
errors.py
from constaia import (
    Constaia,
    ConstaiaError,
    InsufficientCreditsError,
    InvalidRequestError,
    RateLimitError,
)

client = Constaia()

USER_MESSAGES = {
    "file_too_large": "El archivo supera los 20 MB.",
    "unsupported_file_type": "Sube una imagen (JPG, PNG, WEBP, HEIC) o un PDF.",
    "unreadable_image": "No se puede leer la imagen. Haz otra foto con buena luz.",
    "too_many_pages": "El PDF tiene demasiadas páginas.",
}


def check_document(path: str) -> dict:
    try:
        analysis = client.analyze(path, expect="es_dni", checks={"not_expired": True})
    except InvalidRequestError as exc:
        return {"error": USER_MESSAGES.get(exc.code or "", "El documento no se ha podido procesar.")}
    except (InsufficientCreditsError, RateLimitError) as exc:
        print(f"Constaia {exc.status} {exc.code} request_id={exc.request_id}")
        return {"error": "El servicio está ocupado. Inténtalo en unos minutos."}
    except ConstaiaError as exc:
        print(f"Constaia {exc.status} {exc.code} request_id={exc.request_id}: {exc.message}")
        return {"error": "No hemos podido comprobar el documento. Inténtalo más tarde."}
    return {"id": analysis["id"], "status": analysis["verdict"]["status"]}

Lista completa de códigos en Errores.

Reintentos, idempotencia y concurrencia

El SDK ya hace lo que harías a mano:

  • Reintenta 429, 408, 409 idempotency_in_progress, 5xx (salvo 501) y errores de red hasta max_retries veces (2 por defecto), esperando Retry-After (hasta 60 s) o con backoff exponencial con jitter.
  • Envía la misma Idempotency-Key en todos los reintentos, así que un análisis reintentado nunca se cobra dos veces. Pasa la tuya con idempotency_key= para deduplicar entre procesos (ver Idempotencia).
  • Limita a max_concurrency (4 por defecto) las peticiones en vuelo por cliente, entre hilos o tareas asyncio; el resto espera en el cliente.
config.py
from constaia import Constaia

client = Constaia(
    timeout=60.0,        # segundos por intento
    max_retries=3,
    max_concurrency=4,
)

analysis = client.analyze("dni_valid.jpg", expect="es_dni", idempotency_key="user-42-dni", timeout=90)

print(client.last_request_id)   # "req_…": inclúyelo si contactas con soporte
print(client.last_rate_limit)   # RateLimit(limit=10, remaining=9, reset=1, policy='10;w=1', retry_after=None)

El límite es de 2 peticiones por segundo por clave en el plan gratuito y 10 en el de pago (ver Rate limits). Con max_retries=0 gestionas tú los 429.

Cliente async

AsyncConstaia tiene los mismos métodos con await. Reutiliza un único cliente y ciérralo al terminar.

analyze_folder.py
import asyncio
from pathlib import Path

from constaia import AsyncConstaia, ConstaiaError


async def main() -> None:
    files = sorted(Path("inbox").glob("*.pdf"))
    async with AsyncConstaia(max_concurrency=4) as client:
        results = await asyncio.gather(
            *(client.analyze(path, expect="invoice") for path in files),
            return_exceptions=True,
        )
        for path, result in zip(files, results):
            if isinstance(result, ConstaiaError):
                print(path.name, "error", result.code, result.request_id)
            else:
                print(path.name, result["verdict"]["status"], result["fields"].get("total", {}).get("value"))

        async for analysis in client.analyses.list(status="completed", type="invoice"):
            print(analysis["id"])


asyncio.run(main())

max_concurrency ya limita cuántas peticiones salen a la vez, aunque lances todas con gather. Para más de un puñado de ficheros, un lote (client.batches.create(...), hasta 100 documentos) suele ser más simple.

Respuestas 202 y documentos largos

Una llamada síncrona espera hasta 30 s. Si el análisis no ha terminado, la API responde 202 con status queued o processing, y lo mismo ocurre siempre con async_=True (necesario para PDF de más de 30 páginas). El resultado llega por webhook; si no puedes recibir webhooks, consulta el análisis:

poll.py
import time

from constaia import Constaia

client = Constaia()
analysis = client.analyze("contract.pdf", async_=True)
while analysis["status"] in ("queued", "processing"):
    time.sleep(3)
    analysis = client.analyses.get(analysis["id"])
print(analysis["status"], analysis.get("error"))

Verificar webhooks

Constaia firma cada entrega según Standard Webhooks (cabeceras webhook-id, webhook-timestamp y webhook-signature). constaia.webhooks.verify() comprueba la firma y la marca de tiempo (5 minutos de tolerancia) y devuelve el evento. Pásale siempre el cuerpo crudo, nunca un JSON re-serializado.

handler.py
import os

import constaia

SECRET = os.environ["CONSTAIA_WEBHOOK_SECRET"]


def handle(raw_body: bytes, headers) -> int:
    try:
        event = constaia.webhooks.verify(raw_body, headers, SECRET)
    except constaia.WebhookVerificationError:
        return 400
    # Deduplica por headers["webhook-id"]: es el mismo en todos los reintentos.
    match event["type"]:
        case "analysis.completed" | "analysis.review_required" | "analysis.failed":
            analysis = event["data"]
            print(event["type"], analysis["id"], (analysis.get("verdict") or {}).get("status"))
        case "batch.completed":
            print("batch", event["data"]["id"], event["data"]["counts"])
        case "credits.low":
            print("credits", event["data"]["credits_available"])
    return 204

Responde 2xx en menos de 15 s y procesa en segundo plano. Cada guía de framework muestra cómo obtener el cuerpo crudo: request.body en Django, request.get_data() en Flask y await request.body() en FastAPI.

Tests con pytest

Con una clave ck_test_ la API es determinista y gratuita: el resultado depende del nombre del fichero, que debe ser un JPEG, PNG, WEBP, HEIC o PDF real (el tipo se detecta por su contenido). Copia cualquier imagen JPEG a tests/fixtures/ con los nombres dni_valid.jpg, dni_expired.jpg y blurry.jpg.

tests/test_constaia.py
import json
import os
from pathlib import Path

import pytest

import constaia
from constaia import Constaia

FIXTURES = Path(__file__).parent / "fixtures"
needs_test_key = pytest.mark.skipif(
    not os.environ.get("CONSTAIA_API_KEY", "").startswith("ck_test_"),
    reason="CONSTAIA_API_KEY=ck_test_... required",
)


@pytest.fixture(scope="module")
def client():
    with Constaia() as c:
        yield c


@needs_test_key
def test_valid_dni(client):
    analysis = client.analyze(FIXTURES / "dni_valid.jpg", expect="es_dni")
    assert analysis["livemode"] is False
    assert analysis["usage"]["credits"] == 0
    assert analysis["verdict"]["status"] == "valid"
    assert analysis["fields"]["document_number"]["value"] == "12345678Z"


@needs_test_key
def test_expired_dni_is_invalid(client):
    analysis = client.analyze(FIXTURES / "dni_expired.jpg", expect="es_dni")
    assert analysis["verdict"]["status"] == "invalid"
    assert {"code": "not_expired", "severity": "error", "message": "Caducado el 15/06/2020."} in analysis["verdict"]["reasons"]


@needs_test_key
def test_blurry_needs_review(client):
    analysis = client.analyze(FIXTURES / "blurry.jpg", expect="es_dni")
    assert analysis["verdict"]["status"] == "review"
    assert "blurry" in analysis["warnings"]


def test_webhook_signature_roundtrip():
    secret = "whsec_c2VjcmV0LWRlLXBydWViYXMtMzItYnl0ZXMtbGFyZ28="
    body = json.dumps({"type": "analysis.completed", "data": {"id": "an_test"}})
    headers = constaia.webhooks.sign(body, secret)
    assert constaia.webhooks.verify(body.encode(), headers, secret)["data"]["id"] == "an_test"
    with pytest.raises(constaia.WebhookVerificationError):
        constaia.webhooks.verify(body.encode() + b" ", headers, secret)
CONSTAIA_API_KEY=ck_test_... pytest -q

Todos los nombres de fichero y sus resultados están en Modo test.

Sin SDK: httpx o requests

Si no puedes añadir dependencias, la API es JSON sobre HTTPS. El fichero va en el campo file y las opciones en el campo options como cadena JSON. Sin SDK te toca añadir la Idempotency-Key, los reintentos con Retry-After y la lectura de errores (error.code, error.request_id).

raw_httpx.py
import json
import os
import time
import uuid

import httpx

options = {"expect": "es_dni", "checks": {"not_expired": True}, "storage": "none"}
headers = {
    "Authorization": f"Bearer {os.environ['CONSTAIA_API_KEY']}",
    "Idempotency-Key": str(uuid.uuid4()),
}
content = open("dni_valid.jpg", "rb").read()

for attempt in range(3):
    response = httpx.post(
        "https://api.constaia.com/v1/analyze",
        headers=headers,
        files={"file": ("dni_valid.jpg", content, "image/jpeg")},
        data={"options": json.dumps(options)},
        timeout=60,
    )
    if response.status_code in (429, 500, 502, 503, 504) and attempt < 2:
        time.sleep(min(float(response.headers.get("Retry-After", 2**attempt)), 60))
        continue
    break

body = response.json()
if response.is_error:
    error = body["error"]
    raise RuntimeError(f"{response.status_code} {error['code']} {error['message']} ({error['request_id']})")
print(body["verdict"]["status"])

Para URLs o base64 envía JSON: {"file_url": "https://…", "options": {…}} o {"file_base64": "…", "filename": "dni.jpg", "options": {…}}. Verificación de webhooks sin SDK: HMAC-SHA256 sobre <webhook-id>.<webhook-timestamp>.<cuerpo crudo> con el secreto decodificado de base64 (sin el prefijo whsec_), comparando en tiempo constante:

verify_webhook.py
import base64
import hashlib
import hmac
import json
import time


def verify_webhook(payload: bytes, headers, secret: str, tolerance: int = 300) -> dict:
    msg_id = headers.get("webhook-id", "")
    timestamp = headers.get("webhook-timestamp", "")
    signatures = headers.get("webhook-signature", "")
    if not (msg_id and timestamp.isdigit() and signatures) or abs(time.time() - int(timestamp)) > tolerance:
        raise ValueError("invalid webhook headers")
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = "v1," + base64.b64encode(
        hmac.new(key, f"{msg_id}.{timestamp}.".encode() + payload, hashlib.sha256).digest()
    ).decode()
    if not any(hmac.compare_digest(candidate, expected) for candidate in signatures.split()):
        raise ValueError("invalid webhook signature")
    return json.loads(payload)

headers debe permitir buscar sin distinguir mayúsculas (como los de Django, Flask o Starlette).

Checklist de producción

  • Cambia a una clave ck_live_ (requiere email verificado) solo en el entorno de producción.
  • Límite de subida del servidor ≥ 20 MB (por ejemplo client_max_body_size 21M; en nginx) y rechazo previo de ficheros mayores en tu propio código.
  • Timeouts ≥ 60 s en todo el camino: SDK (60 s por intento), worker (gunicorn --timeout 90), proxy y balanceador. O usa async_=True y webhooks.
  • La ruta que recibe ficheros de usuarios exige autenticación y tiene rate limiting propio: cada análisis live consume créditos.
  • expect y checks los fija tu servidor; no aceptes opciones arbitrarias del cliente.
  • Un único cliente por proceso (reutiliza conexiones) y max_concurrency × procesos cerca de tu límite de peticiones por segundo.
  • Webhook con verificación de firma, respuesta 2xx rápida, deduplicación por webhook-id y procesamiento en cola.
  • Registra request_id de cada error; revisa Almacenamiento y privacidad para elegir storage.

Siguientes pasos

En esta página