Django
Valida documentos en Django con el SDK constaia, con un formulario FileField, una vista de Django REST Framework y un webhook csrf_exempt verificado.
Esta guía integra Constaia en un proyecto Django con el SDK oficial constaia: un formulario con FileField, una
vista de Django REST Framework para APIs, un webhook que verifica la firma
con el cuerpo crudo y tests con clave ck_test_. Los detalles del SDK están en la guía de
Python.
Requisitos
- Django 4.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 constaia djangorestframeworkdjangorestframework solo hace falta para la vista de API.
Configuración
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...import os
CONSTAIA_API_KEY = os.environ["CONSTAIA_API_KEY"]
CONSTAIA_WEBHOOK_SECRET = os.environ["CONSTAIA_WEBHOOK_SECRET"]
CONSTAIA_MAX_UPLOAD_BYTES = 20 * 1024 * 1024Django no limita el tamaño de los ficheros subidos (DATA_UPLOAD_MAX_MEMORY_SIZE no cuenta los ficheros y
FILE_UPLOAD_MAX_MEMORY_SIZE solo decide cuándo pasar a disco): el límite de 20 MB va en el formulario y en el proxy.
Cliente y reglas de validación
Un único cliente por proceso y una función que fija expect y checks en el servidor:
from django.conf import settings
from constaia import Constaia
from constaia.types import Analysis
client = Constaia(api_key=settings.CONSTAIA_API_KEY, timeout=60.0, max_retries=2)
def verify_identity(uploaded_file, user, language: str = "es") -> Analysis:
return client.analyze(
uploaded_file,
filename=uploaded_file.name,
expect=["es_dni", "es_nie", "passport"],
checks={
"not_expired": True,
"min_age_years": 18,
"holder": {"full_name": user.get_full_name()},
},
storage="none",
language=language if language in ("es", "en", "pt", "fr") else "es",
metadata={"user_id": str(user.pk)},
)El SDK acepta el UploadedFile de Django tal cual; filename=uploaded_file.name conserva el nombre original, que en
modo test decide la respuesta.
from django.conf import settings
from django.db import models
class IdentityDocument(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
constaia_id = models.CharField(max_length=64, unique=True)
status = models.CharField(max_length=16) # valid | invalid | review | queued | processing | failed
document_number = models.CharField(max_length=32, blank=True)
created_at = models.DateTimeField(auto_now_add=True)Formulario y vista
from django import forms
from django.conf import settings
from django.core.validators import FileExtensionValidator
class IdentityDocumentForm(forms.Form):
document = forms.FileField(
label="DNI, NIE o pasaporte",
validators=[FileExtensionValidator(["jpg", "jpeg", "png", "webp", "heic", "pdf"])],
)
def clean_document(self):
document = self.cleaned_data["document"]
if document.size > settings.CONSTAIA_MAX_UPLOAD_BYTES:
raise forms.ValidationError("El archivo supera los 20 MB.")
return documentimport logging
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from constaia import ConstaiaError, InsufficientCreditsError, InvalidRequestError, RateLimitError
from .constaia_client import verify_identity
from .forms import IdentityDocumentForm
from .models import IdentityDocument
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.",
}
@login_required
def upload_identity(request):
form = IdentityDocumentForm(request.POST or None, request.FILES or None)
if request.method == "POST" and form.is_valid():
try:
analysis = verify_identity(form.cleaned_data["document"], request.user)
except InvalidRequestError as exc:
form.add_error("document", USER_MESSAGES.get(exc.code or "", "El documento no se ha podido procesar."))
except (InsufficientCreditsError, RateLimitError) as exc:
logger.error("Constaia %s %s request_id=%s", exc.status, exc.code, exc.request_id)
form.add_error("document", "El servicio está ocupado. Inténtalo en unos minutos.")
except ConstaiaError as exc:
logger.exception("Constaia %s %s request_id=%s", exc.status, exc.code, exc.request_id)
form.add_error("document", "No hemos podido comprobar el documento. Inténtalo más tarde.")
else:
verdict = analysis["verdict"]
if verdict and verdict["status"] == "invalid":
for reason in verdict["reasons"]:
if reason["severity"] == "error":
form.add_error("document", reason["message"])
else:
number = analysis["fields"].get("document_number") or analysis["fields"].get("nie_number") or {}
IdentityDocument.objects.create(
user=request.user,
constaia_id=analysis["id"],
status=verdict["status"] if verdict else analysis["status"],
document_number=number.get("value") or "",
)
messages.success(request, "Documento recibido.")
return redirect("identity-upload")
return render(request, "documents/upload.html", {"form": form})<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Comprobar</button>
</form>Si el análisis tarda más de 30 s la API responde 202 y analysis["status"] es queued o processing con
verdict a None: se guarda así y el webhook lo actualiza. El estado review se acepta y se deja para
revisión humana.
Vista de API con Django REST Framework
Para una SPA, una app móvil o el widget, expón un endpoint que recibe multipart/form-data. El
widget envía el fichero en el campo file y un campo options del que solo se acepta language.
import json
import logging
from django.conf import settings
from rest_framework import serializers, status
from rest_framework.parsers import MultiPartParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
from rest_framework.views import APIView
from constaia import ConstaiaError, InvalidRequestError, RateLimitError
from .constaia_client import verify_identity
logger = logging.getLogger(__name__)
class DocumentUploadSerializer(serializers.Serializer):
file = serializers.FileField()
options = serializers.CharField(required=False, allow_blank=True)
def validate_file(self, value):
if value.size > settings.CONSTAIA_MAX_UPLOAD_BYTES:
raise serializers.ValidationError("El archivo supera los 20 MB.")
return value
class IdentityDocumentAPIView(APIView):
parser_classes = [MultiPartParser]
permission_classes = [IsAuthenticated]
throttle_classes = [UserRateThrottle]
def post(self, request):
serializer = DocumentUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
language = json.loads(serializer.validated_data.get("options") or "{}").get("language", "es")
except (ValueError, AttributeError):
language = "es"
try:
analysis = verify_identity(serializer.validated_data["file"], request.user, language)
except InvalidRequestError as exc:
return Response({"error": {"code": exc.code, "message": exc.message}}, status=422)
except RateLimitError:
return Response({"error": {"message": "Inténtalo en unos minutos."}}, status=503)
except ConstaiaError as exc:
logger.error("Constaia %s %s request_id=%s", exc.status, exc.code, exc.request_id)
return Response({"error": {"message": "No hemos podido comprobar el documento."}}, status=502)
return Response(
{key: analysis.get(key) for key in ("id", "object", "status", "document", "verdict", "warnings")},
status=status.HTTP_200_OK,
)REST_FRAMEWORK = {
"DEFAULT_THROTTLE_RATES": {"user": "20/hour"},
}La respuesta incluye solo lo que el navegador necesita (verdict.status, verdict.reasons[].message, warnings);
los campos extraídos se quedan en el servidor.
Webhook
El webhook verifica la firma con request.body, el cuerpo crudo en bytes, está exento de CSRF (la firma es la
autenticación) y deduplica por webhook-id con la caché de Django.
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import constaia
from .models import IdentityDocument
@csrf_exempt
@require_POST
def constaia_webhook(request):
try:
event = constaia.webhooks.verify(request.body, request.headers, settings.CONSTAIA_WEBHOOK_SECRET)
except constaia.WebhookVerificationError:
return HttpResponse(status=400)
if not cache.add(f"constaia:webhook:{request.headers['webhook-id']}", True, timeout=4 * 86400):
return HttpResponse(status=204)
match event["type"]:
case "analysis.completed" | "analysis.review_required" | "analysis.failed":
analysis = event["data"]
status = (analysis.get("verdict") or {}).get("status") or analysis["status"]
IdentityDocument.objects.filter(constaia_id=analysis["id"]).update(status=status)
case "credits.low":
pass # avisa a tu equipo: event["data"]["credits_available"]
return HttpResponse(status=204)from django.urls import path
from . import api, views, webhooks
urlpatterns = [
path("identity/", views.upload_identity, name="identity-upload"),
path("api/identity/", api.IdentityDocumentAPIView.as_view(), name="identity-api"),
path("webhooks/constaia/", webhooks.constaia_webhook, name="constaia-webhook"),
]Crea el endpoint en el panel con la URL https://tu-dominio.com/webhooks/constaia/ y guarda el secret en
CONSTAIA_WEBHOOK_SECRET. Responde 2xx en menos de 15 s; si el trabajo es pesado, pásalo a una tarea
(Celery). Formato y reintentos en Webhooks.
Errores
| HTTP | Excepción | code habitual | Respuesta al usuario |
|---|---|---|---|
| 400, 413, 415, 422 | InvalidRequestError | file_too_large, unsupported_file_type, unreadable_image, invalid_parameter | Pide otro fichero |
| 401 | AuthenticationError | invalid_api_key | Error de configuración: revisa CONSTAIA_API_KEY |
| 402 | InsufficientCreditsError | insufficient_credits | "Inténtalo más tarde"; recarga créditos |
| 429 | RateLimitError | rate_limited | Ya reintentado por el SDK |
| 5xx | APIError | internal_error, live_mode_unavailable | "Inténtalo más tarde" |
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
documents/tests/fixtures/ como dni_valid.jpg y dni_expired.jpg.
import json
import os
from pathlib import Path
from unittest import skipUnless
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.urls import reverse
import constaia
from documents.models import IdentityDocument
FIXTURES = Path(__file__).parent / "fixtures"
TEST_KEY = os.environ.get("CONSTAIA_API_KEY", "").startswith("ck_test_")
def upload(name: str) -> SimpleUploadedFile:
return SimpleUploadedFile(name, (FIXTURES / name).read_bytes(), content_type="image/jpeg")
class IdentityUploadTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
"maria", password="test-password", first_name="María", last_name="García López"
)
self.client.force_login(self.user)
@skipUnless(TEST_KEY, "needs a ck_test_ key")
def test_valid_dni_is_saved(self):
self.client.post(reverse("identity-upload"), {"document": upload("dni_valid.jpg")})
doc = IdentityDocument.objects.get(user=self.user)
self.assertEqual(doc.status, "valid")
self.assertEqual(doc.document_number, "12345678Z")
@skipUnless(TEST_KEY, "needs a ck_test_ key")
def test_expired_dni_shows_reason(self):
response = self.client.post(reverse("identity-upload"), {"document": upload("dni_expired.jpg")})
self.assertContains(response, "Caducado el 15/06/2020.")
def test_webhook_signature(self):
body = json.dumps({"type": "analysis.completed", "data": {"id": "an_test", "status": "completed"}})
headers = constaia.webhooks.sign(body, settings.CONSTAIA_WEBHOOK_SECRET)
url = reverse("constaia-webhook")
ok = self.client.post(url, data=body, content_type="application/json", headers=headers)
bad = self.client.post(url, data=body, content_type="application/json",
headers={**headers, "webhook-signature": "v1,bad"})
self.assertEqual(ok.status_code, 204)
self.assertEqual(bad.status_code, 400)En test_expired_dni_shows_reason el titular (María García López) no coincide con el del DNI caducado (Juan Pérez
Sánchez), así que además del motivo de caducidad aparece el de holder. CONSTAIA_WEBHOOK_SECRET debe tener el
formato whsec_<base64>. Más ficheros en Modo test.
Checklist de producción
- Proxy con
client_max_body_size 21M;(nginx) y límite de 20 MB en el formulario y el serializer. - Timeouts ≥ 60 s:
gunicorn --timeout 90,proxy_read_timeout 90s;y balanceador. O usaasync_=Truecon webhook, o una tarea de Celery. - Vistas de subida con
login_required/IsAuthenticatedy throttling: cada análisis live consume créditos. expectychecksfijos enverify_identity(); del navegador solo se acepta el fichero ylanguage.CONSTAIA_API_KEY=ck_live_...solo en producción, fuera del repositorio.- Webhook con
csrf_exempt, firma verificada y deduplicación porwebhook-iden una caché compartida (Redis, Memcached o base de datos, no la caché local por proceso). - Revisa Almacenamiento y privacidad para elegir
storage.
Siguientes pasos
Python
Integra Constaia en Python con el SDK oficial constaia, síncrono y async, con reintentos, errores tipados, webhooks verificados y tests con pytest.
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().