Constaia
Integrations

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 djangorestframework

djangorestframework is only needed for the API view.

Configuration

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...
config/settings.py
import os

CONSTAIA_API_KEY = os.environ["CONSTAIA_API_KEY"]
CONSTAIA_WEBHOOK_SECRET = os.environ["CONSTAIA_WEBHOOK_SECRET"]

CONSTAIA_MAX_UPLOAD_BYTES = 20 * 1024 * 1024

Django 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:

documents/constaia_client.py
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.

documents/models.py
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

documents/forms.py
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 document
documents/views.py
import 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})
documents/templates/documents/upload.html
<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.

documents/api.py
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,
        )
config/settings.py
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.

documents/webhooks.py
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)
documents/urls.py
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

HTTPExceptionTypical codeResponse to the user
400, 413, 415, 422InvalidRequestErrorfile_too_large, unsupported_file_type, unreadable_image, invalid_parameterAsk for another file
401AuthenticationErrorinvalid_api_keyConfiguration error: check CONSTAIA_API_KEY
402InsufficientCreditsErrorinsufficient_credits"Try again later"; top up credits
429RateLimitErrorrate_limitedAlready retried by the SDK
5xxAPIErrorinternal_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.

documents/tests/test_constaia.py
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 use async_=True with a webhook, or a Celery task.
  • Upload views with login_required / IsAuthenticated and throttling: every live analysis consumes credits.
  • expect and checks fixed in verify_identity(); from the browser only the file and language are accepted.
  • CONSTAIA_API_KEY=ck_live_... only in production, outside the repository.
  • Webhook with csrf_exempt, verified signature and deduplication on webhook-id in a shared cache (Redis, Memcached or database, not the per-process local cache).
  • Read Storage and privacy to choose storage.

Next steps

On this page