Constaia
Integrations

FastAPI

Validate documents in FastAPI with the async constaia client, UploadFile, pydantic response models, BackgroundTasks and a webhook that verifies the signature.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

This guide integrates Constaia into FastAPI with AsyncConstaia, the async client of the official SDK: an endpoint that receives an UploadFile, pydantic models to return only what is needed, BackgroundTasks for follow-up work and a webhook that verifies the signature with await request.body(). SDK details are in the Python guide.

Requirements

  • A recent FastAPI with Python ≥ 3.10 and python-multipart (included in fastapi[standard]).
  • A test key ck_test_… from the dashboard. In test mode no credits are consumed and the result depends on the file name.

Installation

pip install "fastapi[standard]" constaia

Environment variables

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

AsyncConstaia() reads CONSTAIA_API_KEY from the environment. The key stays on the server.

Response models

The full analysis includes the extracted fields. You only return the verdict to the browser, so define the subset with pydantic and use it as 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] = []

Fields you don't declare (for example fields or checks) are left out of the response.

Application

One AsyncConstaia for the whole application, created and closed in lifespan. The server sets expect and checks; from the browser only the file and the language are accepted.

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": "The file is larger than 20 MB.",
    "unsupported_file_type": "Upload an image (JPG, PNG, WEBP, HEIC) or a PDF.",
    "unreadable_image": "We can't read the image. Take another photo in good light.",
    "unreadable_pdf": "We can't read the 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:
    # Save to your database, send emails, etc. Runs after the response is sent.
    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,
):
    # Add your authentication dependency and a rate limit here: every live analysis consumes credits.
    content = await file.read()
    if not content:
        return error("No document received.", 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 "", "The document could not be processed."), 422)
    except (InsufficientCreditsError, RateLimitError) as exc:
        logger.error("Constaia %s %s request_id=%s", exc.status, exc.code, exc.request_id)
        return error("The service is busy. Try again in a few minutes.", 503)
    except ConstaiaError as exc:
        logger.error("Constaia %s %s request_id=%s", exc.status, exc.code, exc.request_id)
        return error("We could not check the document. Please try again later.", 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)

    # Deduplicate on request.headers["webhook-id"] (Redis, database) before processing.
    if event["type"] in ("analysis.completed", "analysis.review_required", "analysis.failed"):
        background_tasks.add_task(save_analysis, event["data"])
    return Response(status_code=204)

Notes:

  • await file.read() reads the file (up to 20 MB) and filename=file.filename keeps the original name, which decides the response in test mode.
  • max_concurrency=8 limits how many Constaia requests are in flight in this process; the rest wait in the client without blocking the event loop. The API limit is 2 requests per second per key on the free plan (10 on paid): see Rate limits.
  • BackgroundTasks runs in the same process after the response is sent: use it to store the result or send an email. For long work, or work that must survive a restart, use async_=True with the webhook, or Celery.
  • If the analysis takes longer than 30 s the API answers 202 with status queued or processing and verdict set to None; the result arrives at the webhook.

Frontend with the widget

The widget sends the file in the file field plus an options field, exactly what /documents expects:

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="en"></constaia-upload>

Webhook

/webhooks/constaia reads the raw body with await request.body() and verifies it before parsing. Don't declare the body as a pydantic model on this route: FastAPI would parse it and re-serialised JSON doesn't match the signature. Create the endpoint in the dashboard with the URL https://your-domain.com/webhooks/constaia, store the secret in CONSTAIA_WEBHOOK_SECRET and answer 2xx within 15 s. Format and retries in Webhooks.

Errors

HTTPExceptionTypical codeWhat to do
400, 413, 415, 422InvalidRequestErrorfile_too_large, unsupported_file_type, unreadable_image, invalid_parameterAsk for another file or fix the option
401AuthenticationErrorinvalid_api_keyCheck CONSTAIA_API_KEY
402InsufficientCreditsErrorinsufficient_creditsTop up credits
429RateLimitErrorrate_limitedAlready retried by the SDK; retry_after gives the wait
5xxAPIErrorinternal_error, live_mode_unavailableAlready retried; live_mode_unavailable: use ck_test_
—APIConnectionError, APITimeoutError—Network or timeout after the retries

Always log exc.request_id. Full list in Errors.

Tests

With CONSTAIA_API_KEY=ck_test_... the API answers based on the file name and charges nothing. Copy any real JPEG into tests/fixtures/ as dni_valid.jpg, dni_expired.jpg and blurry.jpg. TestClient used as a context manager runs 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 must be in whsec_<base64> format. More files in Test mode.

Production checklist

  • client_max_body_size 21M; in nginx (or your proxy's limit) and the endpoint's 20 MB check.
  • Timeouts ≥ 60 s: gunicorn -k uvicorn.workers.UvicornWorker --timeout 90, proxy_read_timeout 90s; and load balancer.
  • Authentication dependency and rate limiting on /documents (for example slowapi): every live analysis consumes credits.
  • expect and checks fixed on the server; from the browser you accept only the file and language.
  • One AsyncConstaia per process, created in lifespan; max_concurrency × processes close to your requests-per-second limit.
  • CONSTAIA_API_KEY=ck_live_... only in production.
  • Webhook with signature verified over the raw body, deduplicated on webhook-id and answering quickly.
  • Read Storage and privacy to choose storage.

Next steps

Sur cette page