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 dehttpx. - 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 constaiaVariables de entorno
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).
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.
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:
| Entrada | Ejemplo |
|---|---|
Ruta (str o pathlib.Path) | client.analyze("scans/dni.jpg") |
bytes | client.analyze(data, filename="dni.jpg") |
| Fichero binario abierto | client.analyze(open("dni.pdf", "rb")), UploadedFile de Django, FileStorage.stream de Flask |
Tupla (filename, bytes) | client.analyze(("dni.jpg", data)) |
| URL remota | client.analyze(file_url="https://…/dni.jpg") |
| Base64 | client.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.
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ón | HTTP | Qué hacer |
|---|---|---|
InvalidRequestError | 400, 409, 413, 415, 422 | Mira code (file_too_large, unsupported_file_type, unreadable_image, too_many_pages, invalid_parameter con param…) |
AuthenticationError | 401 | Revisa CONSTAIA_API_KEY |
InsufficientCreditsError | 402 | Recarga créditos en el panel; avisa a tu equipo |
PermissionDeniedError | 403 | La clave no puede hacer esa acción |
NotFoundError | 404 | Análisis borrado, de otra cuenta o creado con keep_results=False |
RateLimitError | 429 | Ya reintentado por el SDK; retry_after indica cuánto esperar |
APIError | 5xx | Ya 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 |
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,409idempotency_in_progress,5xx(salvo501) y errores de red hastamax_retriesveces (2 por defecto), esperandoRetry-After(hasta 60 s) o con backoff exponencial con jitter. - Envía la misma
Idempotency-Keyen todos los reintentos, así que un análisis reintentado nunca se cobra dos veces. Pasa la tuya conidempotency_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.
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.
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:
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.
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 204Responde 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.
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 -qTodos 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).
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:
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 usaasync_=Truey webhooks. - La ruta que recibe ficheros de usuarios exige autenticación y tiene rate limiting propio: cada análisis live consume créditos.
expectycheckslos 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
2xxrápida, deduplicación porwebhook-idy procesamiento en cola. - Registra
request_idde cada error; revisa Almacenamiento y privacidad para elegirstorage.
Siguientes pasos
Slim
Valida documentos en Slim 4 con constaia/constaia-php, con UploadedFileInterface de PSR-7 movido a un temporal o como stream, y un webhook firmado.
Django
Valida documentos en Django con el SDK constaia, con un formulario FileField, una vista de Django REST Framework y un webhook csrf_exempt verificado.