Constaia
Integraciones

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().

Esta guía integra Constaia en una aplicación Flask con el SDK oficial constaia: una ruta que recibe el fichero de request.files (desde un formulario o el widget), un webhook que verifica la firma con el cuerpo crudo y tests con el cliente de pruebas de Flask. Los detalles del SDK están en la guía de Python.

Requisitos

  • Flask 2.2 o posterior y Python ≥ 3.10 (los ejemplos usan match).
  • 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 flask constaia

Variables de entorno

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

Constaia() lee CONSTAIA_API_KEY del entorno. La clave se queda en el servidor.

Aplicación

expect y checks los fija el servidor; del navegador solo se aceptan el fichero y el idioma. MAX_CONTENT_LENGTH corta en Flask las peticiones de más de 21 MB (20 MB de fichero más el resto del formulario).

app.py
import json
import logging
import os

from flask import Flask, abort, jsonify, request

import constaia
from constaia import Constaia, ConstaiaError, InsufficientCreditsError, InvalidRequestError, RateLimitError

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 21 * 1024 * 1024
app.config["CONSTAIA_WEBHOOK_SECRET"] = os.environ["CONSTAIA_WEBHOOK_SECRET"]

client = Constaia(timeout=60.0, max_retries=2)
logger = logging.getLogger(__name__)

ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "webp", "heic", "pdf"}
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.",
}


def error(message: str, status: int):
    return jsonify({"error": {"message": message}}), status


@app.errorhandler(413)
def too_large(_):
    return error("El archivo supera los 20 MB.", 413)


@app.post("/documents")
def documents():
    # Protege esta ruta con tu login (por ejemplo flask_login.login_required): cada análisis live consume créditos.
    upload = request.files.get("file")
    if upload is None or not upload.filename:
        return error("No se ha recibido el documento.", 400)
    if upload.filename.rsplit(".", 1)[-1].lower() not in ALLOWED_EXTENSIONS:
        return error(USER_MESSAGES["unsupported_file_type"], 400)

    try:
        language = json.loads(request.form.get("options") or "{}").get("language", "es")
    except (ValueError, AttributeError):
        language = "es"

    try:
        analysis = client.analyze(
            upload.stream,
            filename=upload.filename,
            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)

    # Guarda aquí analysis["id"] y el veredicto en tu base de datos.
    return jsonify({key: analysis.get(key) for key in ("id", "object", "status", "document", "verdict", "warnings")})


@app.post("/webhooks/constaia")
def constaia_webhook():
    try:
        event = constaia.webhooks.verify(request.get_data(), request.headers, app.config["CONSTAIA_WEBHOOK_SECRET"])
    except constaia.WebhookVerificationError:
        abort(400)

    # Deduplica por request.headers["webhook-id"] (Redis, base de datos) y encola el trabajo pesado.
    match event["type"]:
        case "analysis.completed" | "analysis.review_required" | "analysis.failed":
            analysis = event["data"]
            logger.info("Constaia %s: %s", analysis["id"], (analysis.get("verdict") or {}).get("status"))
        case "credits.low":
            logger.warning("Constaia credits low: %s", event["data"]["credits_available"])

    return "", 204

Notas:

  • upload.stream es un fichero binario que el SDK lee una sola vez; filename=upload.filename conserva el nombre original, que en modo test decide la respuesta.
  • La respuesta incluye solo lo que el widget necesita (verdict.status, verdict.reasons[].message, warnings); los campos extraídos (analysis["fields"]) se quedan en el servidor.
  • 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 por el webhook.
  • Si usas CSRFProtect de Flask-WTF, exime el webhook con csrf.exempt(constaia_webhook) y envía el token CSRF desde el widget en su atributo headers.

Formulario o widget

templates/upload.html
<script type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>
<constaia-upload endpoint="{{ url_for('documents') }}" document="es_dni" lang="es"></constaia-upload>

Un formulario clásico también sirve: <form method="post" enctype="multipart/form-data"> con un <input type="file" name="file"> enviado a /documents.

Webhook

La ruta /webhooks/constaia verifica la firma con request.get_data(), el cuerpo crudo en bytes, antes de parsear nada. No uses request.get_json() para verificar: 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; para trabajo pesado usa Celery. 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_

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.

tests/test_app.py
import io
import json
import os
from pathlib import Path

import pytest

import constaia
from app import 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():
    return app.test_client()


def post_file(http, name: str):
    data = {"file": (io.BytesIO((FIXTURES / name).read_bytes()), name)}
    return http.post("/documents", data=data, content_type="multipart/form-data")


@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 = post_file(http, name)
    assert response.status_code == 200
    assert response.get_json()["verdict"]["status"] == status


def test_webhook_signature(http):
    body = json.dumps({"type": "analysis.completed", "data": {"id": "an_test", "status": "completed"}})
    headers = constaia.webhooks.sign(body, app.config["CONSTAIA_WEBHOOK_SECRET"])

    assert http.post("/webhooks/constaia", data=body, headers=headers, content_type="application/json").status_code == 204
    headers["webhook-signature"] = "v1,bad"
    assert http.post("/webhooks/constaia", data=body, headers=headers, content_type="application/json").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

  • MAX_CONTENT_LENGTH de 21 MB en Flask y client_max_body_size 21M; en nginx.
  • Timeouts ≥ 60 s: gunicorn --timeout 90, proxy_read_timeout 90s; y balanceador. O usa async_=True con webhook, o una tarea de Celery.
  • Ruta de subida con login y límite de frecuencia (por ejemplo Flask-Limiter): cada análisis live consume créditos.
  • expect y checks fijos en la ruta; del navegador solo aceptas el fichero y language.
  • CONSTAIA_API_KEY=ck_live_... solo en producción.
  • Webhook con firma verificada, exento de CSRF, deduplicado por webhook-id y con respuesta rápida.
  • Revisa Almacenamiento y privacidad para elegir storage.

Siguientes pasos

En esta página