FastAPI
Valida documentos en FastAPI con el cliente async de constaia, UploadFile, modelos pydantic de respuesta, BackgroundTasks y un webhook que verifica la firma.
Esta guía integra Constaia en FastAPI con AsyncConstaia, el cliente asíncrono del SDK oficial: un endpoint que
recibe un UploadFile, modelos pydantic para devolver solo lo necesario, BackgroundTasks para el trabajo posterior y
un webhook que verifica la firma con await request.body(). Los detalles del SDK están en la guía de
Python.
Requisitos
- FastAPI reciente con Python ≥ 3.10 y
python-multipart(incluido enfastapi[standard]). - Una clave de test
ck_test_…del panel. En modo test no se consumen créditos y el resultado depende del nombre del fichero.
Instalación
pip install "fastapi[standard]" constaiaVariables de entorno
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...AsyncConstaia() lee CONSTAIA_API_KEY del entorno. La clave se queda en el servidor.
Modelos de respuesta
El análisis completo incluye los campos extraídos. Al navegador solo le devuelves el veredicto, así que define el
subconjunto con pydantic y úsalo como response_model:
from typing import Literal, Optional
from pydantic import BaseModel
class Reason(BaseModel):
code: str
severity: Literal["info", "warning", "error"]
message: str
class Verdict(BaseModel):
expected: list[str]
match: bool
status: Literal["valid", "invalid", "review"]
reasons: list[Reason]
class DocumentInfo(BaseModel):
type: str
label: str
confidence: float
class AnalysisOut(BaseModel):
id: str
object: Literal["analysis"] = "analysis"
status: Literal["queued", "processing", "completed", "failed"]
document: Optional[DocumentInfo] = None
verdict: Optional[Verdict] = None
warnings: list[str] = []Los campos que no declares (por ejemplo fields o checks) no salen en la respuesta.
Aplicación
Un único AsyncConstaia para toda la aplicación, creado y cerrado en lifespan. expect y checks los fija el
servidor; del navegador solo se aceptan el fichero y el idioma.
import json
import logging
import os
from contextlib import asynccontextmanager
from typing import Annotated, Optional
from fastapi import BackgroundTasks, FastAPI, File, Form, Request, Response, UploadFile
from fastapi.responses import JSONResponse
import constaia
from constaia import AsyncConstaia, ConstaiaError, InsufficientCreditsError, InvalidRequestError, RateLimitError
from constaia.types import Analysis
from models import AnalysisOut
MAX_BYTES = 20 * 1024 * 1024
WEBHOOK_SECRET = os.environ["CONSTAIA_WEBHOOK_SECRET"]
logger = logging.getLogger(__name__)
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.",
"unreadable_pdf": "No se puede leer el PDF.",
}
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.constaia = AsyncConstaia(timeout=60.0, max_retries=2, max_concurrency=8)
yield
await app.state.constaia.close()
app = FastAPI(lifespan=lifespan)
def error(message: str, status: int) -> JSONResponse:
return JSONResponse({"error": {"message": message}}, status_code=status)
def save_analysis(analysis: Analysis) -> None:
# Guarda en tu base de datos, envía emails, etc. Se ejecuta tras enviar la respuesta.
logger.info("Constaia %s: %s", analysis["id"], (analysis["verdict"] or {}).get("status"))
@app.post("/documents", response_model=AnalysisOut)
async def documents(
request: Request,
background_tasks: BackgroundTasks,
file: Annotated[UploadFile, File()],
options: Annotated[Optional[str], Form()] = None,
):
# Añade aquí tu dependencia de autenticación y un límite de frecuencia: cada análisis live consume créditos.
content = await file.read()
if not content:
return error("No se ha recibido el documento.", 400)
if len(content) > MAX_BYTES:
return error(USER_MESSAGES["file_too_large"], 413)
try:
language = json.loads(options or "{}").get("language", "es")
except (ValueError, AttributeError):
language = "es"
try:
analysis = await request.app.state.constaia.analyze(
content,
filename=file.filename or "document",
expect=["es_dni", "es_nie", "passport"],
checks={"not_expired": True, "min_age_years": 18},
storage="none",
language=language if language in ("es", "en", "pt", "fr") else "es",
)
except InvalidRequestError as exc:
return error(USER_MESSAGES.get(exc.code or "", "El documento no se ha podido procesar."), 422)
except (InsufficientCreditsError, RateLimitError) as exc:
logger.error("Constaia %s %s request_id=%s", exc.status, exc.code, exc.request_id)
return error("El servicio está ocupado. Inténtalo en unos minutos.", 503)
except ConstaiaError as exc:
logger.error("Constaia %s %s request_id=%s", exc.status, exc.code, exc.request_id)
return error("No hemos podido comprobar el documento. Inténtalo más tarde.", 502)
background_tasks.add_task(save_analysis, analysis)
return analysis
@app.post("/webhooks/constaia", status_code=204)
async def constaia_webhook(request: Request, background_tasks: BackgroundTasks):
payload = await request.body()
try:
event = constaia.webhooks.verify(payload, request.headers, WEBHOOK_SECRET)
except constaia.WebhookVerificationError:
return Response(status_code=400)
# Deduplica por request.headers["webhook-id"] (Redis, base de datos) antes de procesar.
if event["type"] in ("analysis.completed", "analysis.review_required", "analysis.failed"):
background_tasks.add_task(save_analysis, event["data"])
return Response(status_code=204)Notas:
await file.read()lee el fichero (hasta 20 MB) yfilename=file.filenameconserva el nombre original, que en modo test decide la respuesta.max_concurrency=8limita cuántas peticiones a Constaia hay en vuelo en este proceso; el resto espera en el cliente sin bloquear el bucle de eventos. El límite de la API es de 10 peticiones por segundo por clave por defecto: ver Rate limits.BackgroundTasksse ejecuta en el mismo proceso tras enviar la respuesta: sirve para guardar el resultado o enviar un email. Para trabajo largo o que deba sobrevivir a un reinicio, usaasync_=Truecon el webhook, o Celery.- Si el análisis tarda más de 30 s la API responde
202constatusqueuedoprocessingyverdictaNone; el resultado llega al webhook.
Frontend con el widget
El widget envía el fichero en el campo file y un campo options, justo lo que espera
/documents:
<script type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>
<constaia-upload endpoint="/documents" document="es_dni" lang="es"></constaia-upload>Webhook
/webhooks/constaia lee el cuerpo crudo con await request.body() y lo verifica antes de parsear. No declares el
cuerpo como modelo pydantic en esta ruta: FastAPI lo parsearía y el JSON re-serializado no coincide con la firma. Crea
el endpoint en el panel con la URL https://tu-dominio.com/webhooks/constaia, guarda el secret en
CONSTAIA_WEBHOOK_SECRET y responde 2xx en menos de 15 s. Formato y reintentos en Webhooks.
Errores
| HTTP | Excepción | code habitual | Qué hacer |
|---|---|---|---|
| 400, 413, 415, 422 | InvalidRequestError | file_too_large, unsupported_file_type, unreadable_image, invalid_parameter | Pide otro fichero o corrige la opción |
| 401 | AuthenticationError | invalid_api_key | Revisa CONSTAIA_API_KEY |
| 402 | InsufficientCreditsError | insufficient_credits | Recarga créditos |
| 429 | RateLimitError | rate_limited | Ya reintentado por el SDK; retry_after indica la espera |
| 5xx | APIError | internal_error, live_mode_unavailable | Ya reintentado; live_mode_unavailable: usa ck_test_ |
| — | APIConnectionError, APITimeoutError | — | Red o timeout tras los reintentos |
Registra siempre exc.request_id. Lista completa en Errores.
Tests
Con CONSTAIA_API_KEY=ck_test_... la API responde según el nombre del fichero y no cobra. Copia cualquier JPEG real a
tests/fixtures/ como dni_valid.jpg, dni_expired.jpg y blurry.jpg. TestClient usado como context manager
ejecuta lifespan.
import json
import os
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import constaia
from main import WEBHOOK_SECRET, app
FIXTURES = Path(__file__).parent / "fixtures"
needs_test_key = pytest.mark.skipif(
not os.environ.get("CONSTAIA_API_KEY", "").startswith("ck_test_"), reason="needs a ck_test_ key"
)
@pytest.fixture
def http():
with TestClient(app) as client:
yield client
@needs_test_key
@pytest.mark.parametrize("name, status", [("dni_valid.jpg", "valid"), ("dni_expired.jpg", "invalid"), ("blurry.jpg", "review")])
def test_verdicts(http, name, status):
response = http.post("/documents", files={"file": (name, (FIXTURES / name).read_bytes(), "image/jpeg")})
assert response.status_code == 200
body = response.json()
assert body["verdict"]["status"] == status
assert "fields" not in body
def test_webhook_signature(http):
body = json.dumps({"type": "analysis.completed", "data": {"id": "an_test", "status": "completed", "verdict": None}})
headers = constaia.webhooks.sign(body, WEBHOOK_SECRET)
assert http.post("/webhooks/constaia", content=body, headers=headers).status_code == 204
assert http.post("/webhooks/constaia", content=body, headers={**headers, "webhook-signature": "v1,bad"}).status_code == 400CONSTAIA_API_KEY=ck_test_... CONSTAIA_WEBHOOK_SECRET=whsec_... pytest -qCONSTAIA_WEBHOOK_SECRET debe tener el formato whsec_<base64>. Más ficheros en Modo test.
Checklist de producción
client_max_body_size 21M;en nginx (o el límite de tu proxy) y la comprobación de 20 MB del endpoint.- Timeouts ≥ 60 s:
gunicorn -k uvicorn.workers.UvicornWorker --timeout 90,proxy_read_timeout 90s;y balanceador. - Dependencia de autenticación y límite de frecuencia en
/documents(por ejemploslowapi): cada análisis live consume créditos. expectychecksfijos en el servidor; del navegador solo aceptas el fichero ylanguage.- Un
AsyncConstaiapor proceso, creado enlifespan;max_concurrency× procesos cerca de tu límite de peticiones por segundo. CONSTAIA_API_KEY=ck_live_...solo en producción.- Webhook con firma verificada sobre el cuerpo crudo, deduplicado por
webhook-idy con respuesta rápida. - Revisa Almacenamiento y privacidad para elegir
storage.
Siguientes pasos
Flask
Valida documentos en Flask con el SDK de Python constaia, leyendo el fichero de request.files y verificando los webhooks con request.get_data().
Celery
Analiza documentos en segundo plano con Celery y el SDK constaia, con reintentos según Retry-After, rate_limit, idempotencia y lotes de hasta 100.