Django
Validate documents in Django with the constaia SDK, with a FileField form, a Django REST Framework view and a verified csrf_exempt webhook.
This guide integrates Constaia into a Django project with the official constaia SDK: a form with a FileField, a
Django REST Framework view for APIs, a webhook that verifies the signature
with the raw body and tests with a ck_test_ key. SDK details are in the Python guide.
Requirements
- Django 4.2 or later and Python ≥ 3.10 (the examples use
match). - 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 constaia djangorestframeworkdjangorestframework is only needed for the API view.
Configuration
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 does not limit the size of uploaded files (DATA_UPLOAD_MAX_MEMORY_SIZE doesn't count files and
FILE_UPLOAD_MAX_MEMORY_SIZE only decides when to spill to disk): the 20 MB limit goes in the form and in the proxy.
Client and validation rules
One client per process and a function that sets expect and checks on the server:
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)},
)The SDK accepts Django's UploadedFile as is; filename=uploaded_file.name keeps the original name, which decides the
response in test mode.
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)Form and view
from django import forms
from django.conf import settings
from django.core.validators import FileExtensionValidator
class IdentityDocumentForm(forms.Form):
document = forms.FileField(
label="ID card, NIE or passport",
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("The file is larger than 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": "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.",
}
@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 "", "The document could not be processed."))
except (InsufficientCreditsError, RateLimitError) as exc:
logger.error("Constaia %s %s request_id=%s", exc.status, exc.code, exc.request_id)
form.add_error("document", "The service is busy. Try again in a few minutes.")
except ConstaiaError as exc:
logger.exception("Constaia %s %s request_id=%s", exc.status, exc.code, exc.request_id)
form.add_error("document", "We could not check the document. Please try again later.")
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, "Document received.")
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">Check</button>
</form>If the analysis takes longer than 30 s the API answers 202 and analysis["status"] is queued or processing with
verdict set to None: it is stored like that and the webhook updates it. The review status is accepted and left
for human review.
API view with Django REST Framework
For a SPA, a mobile app or the widget, expose an endpoint that receives multipart/form-data. The
widget sends the file in the file field and an options field from which only language is accepted.
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("The file is larger than 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": "Try again in a few minutes."}}, 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": "We could not check the document."}}, 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"},
}The response contains only what the browser needs (verdict.status, verdict.reasons[].message, warnings);
extracted fields stay on the server.
Webhook
The webhook verifies the signature with request.body, the raw body as bytes, is CSRF-exempt (the signature is the
authentication) and deduplicates on webhook-id with Django's cache.
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 # alert your team: 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"),
]Create the endpoint in the dashboard with the URL https://your-domain.com/webhooks/constaia/ and store the secret in
CONSTAIA_WEBHOOK_SECRET. Answer 2xx within 15 s; if the work is heavy, hand it to a task
(Celery). Format and retries in Webhooks.
Errors
| HTTP | Exception | Typical code | Response to the user |
|---|---|---|---|
| 400, 413, 415, 422 | InvalidRequestError | file_too_large, unsupported_file_type, unreadable_image, invalid_parameter | Ask for another file |
| 401 | AuthenticationError | invalid_api_key | Configuration error: check CONSTAIA_API_KEY |
| 402 | InsufficientCreditsError | insufficient_credits | "Try again later"; top up credits |
| 429 | RateLimitError | rate_limited | Already retried by the SDK |
| 5xx | APIError | internal_error, live_mode_unavailable | "Try again later" |
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
documents/tests/fixtures/ as dni_valid.jpg and dni_expired.jpg. Messages are in Spanish because language
defaults to es.
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)In test_expired_dni_shows_reason the holder (María García López) doesn't match the expired ID card (Juan Pérez
Sánchez), so besides the expiry reason the holder reason appears too. CONSTAIA_WEBHOOK_SECRET must be in
whsec_<base64> format. More files in Test mode.
Production checklist
- Proxy with
client_max_body_size 21M;(nginx) and a 20 MB limit in the form and the serializer. - Timeouts ≥ 60 s:
gunicorn --timeout 90,proxy_read_timeout 90s;and load balancer. Or useasync_=Truewith a webhook, or a Celery task. - Upload views with
login_required/IsAuthenticatedand throttling: every live analysis consumes credits. expectandchecksfixed inverify_identity(); from the browser only the file andlanguageare accepted.CONSTAIA_API_KEY=ck_live_...only in production, outside the repository.- Webhook with
csrf_exempt, verified signature and deduplication onwebhook-idin a shared cache (Redis, Memcached or database, not the per-process local cache). - Read Storage and privacy to choose
storage.