Constaia
Integraciones

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 en fastapi[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]" constaia

Variables de entorno

.env
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:

models.py
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.

main.py
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) y filename=file.filename conserva el nombre original, que en modo test decide la respuesta.
  • max_concurrency=8 limita 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.
  • BackgroundTasks se 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, usa async_=True con el webhook, o Celery.
  • Si el análisis tarda más de 30 s la API responde 202 con status queued o processing y verdict a None; 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:

static/upload.html
<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

HTTPExcepcióncode habitualQué hacer
400, 413, 415, 422InvalidRequestErrorfile_too_large, unsupported_file_type, unreadable_image, invalid_parameterPide otro fichero o corrige la opción
401AuthenticationErrorinvalid_api_keyRevisa CONSTAIA_API_KEY
402InsufficientCreditsErrorinsufficient_creditsRecarga créditos
429RateLimitErrorrate_limitedYa reintentado por el SDK; retry_after indica la espera
5xxAPIErrorinternal_error, live_mode_unavailableYa 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.

tests/test_main.py
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 == 400
CONSTAIA_API_KEY=ck_test_... CONSTAIA_WEBHOOK_SECRET=whsec_... pytest -q

CONSTAIA_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 ejemplo slowapi): cada análisis live consume créditos.
  • expect y checks fijos en el servidor; del navegador solo aceptas el fichero y language.
  • Un AsyncConstaia por proceso, creado en lifespan; 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-id y con respuesta rápida.
  • Revisa Almacenamiento y privacidad para elegir storage.

Siguientes pasos

En esta página