FastAPI
Validate documents in FastAPI with the async constaia client, UploadFile, pydantic response models, BackgroundTasks and a webhook that verifies the signature.
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 infastapi[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]" constaiaEnvironment variables
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:
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.
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) andfilename=file.filenamekeeps the original name, which decides the response in test mode.max_concurrency=8limits 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.BackgroundTasksruns 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, useasync_=Truewith the webhook, or Celery.- If the analysis takes longer than 30 s the API answers
202withstatusqueuedorprocessingandverdictset toNone; 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:
<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
| HTTP | Exception | Typical code | What to do |
|---|---|---|---|
| 400, 413, 415, 422 | InvalidRequestError | file_too_large, unsupported_file_type, unreadable_image, invalid_parameter | Ask for another file or fix the option |
| 401 | AuthenticationError | invalid_api_key | Check CONSTAIA_API_KEY |
| 402 | InsufficientCreditsError | insufficient_credits | Top up credits |
| 429 | RateLimitError | rate_limited | Already retried by the SDK; retry_after gives the wait |
| 5xx | APIError | internal_error, live_mode_unavailable | Already 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.
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 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 exampleslowapi): every live analysis consumes credits. expectandchecksfixed on the server; from the browser you accept only the file andlanguage.- One
AsyncConstaiaper process, created inlifespan;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-idand answering quickly. - Read Storage and privacy to choose
storage.
Next steps
Flask
Validate documents in Flask with the constaia Python SDK, reading the file from request.files and verifying webhooks with request.get_data().
Celery
Analyse documents in the background with Celery and the constaia SDK, with Retry-After aware retries, rate_limit, idempotency keys and batches of up to 100.