DEV Community

Cover image for Aadhaar eSign in Django: send PDFs for signature and verify webhooks
Atul Thakur
Atul Thakur

Posted on

Aadhaar eSign in Django: send PDFs for signature and verify webhooks

Indian businesses run on signed PDFs: rent agreements, offer letters, NDAs, loan sanction letters. Aadhaar eSign lets a signer sign with an OTP sent to their Aadhaar-linked mobile, and the result is a valid electronic signature under Section 3A of the IT Act 2000. No printer, no courier, no USB token.

This tutorial adds Aadhaar eSign to a Django project using the SignYu API. SignYu sits in front of a licensed eSign provider (eMudhra), so you work with a plain REST API and webhooks instead of gateway encryption and redirects. By the end you will have:

  1. A Contract model that tracks each document.
  2. A view that uploads a PDF, adds signers, and sends it.
  3. A webhook view that verifies the HMAC signature and updates the model.
  4. A view that downloads the completion certificate.

There is also a Django guide at signyu.com/aadhaar-esign-api/integrations/django.

Prerequisites

  • Python 3.9+ and Django 4.2+ (the code also works on Django 5).
  • A SignYu account with API access: ₹999/month with a 3-day free trial, and each signature uses one credit (₹15 per signature on packs of 10 or more, GST included). Create an API key under Developers at https://signyu.com/app/developers.

There is no sandbox, because every signature is a real Aadhaar eSign. Use your own phone for testing and keep a few credits aside.

1. Install and configure

pip install signyu
Enter fullscreen mode Exit fullscreen mode

Keep secrets in environment variables, not in settings.py:

export SIGNYU_API_KEY=sk_live_xxxxxxxxxxxxxxxx
export SIGNYU_WEBHOOK_SECRET=your_endpoint_signing_secret
Enter fullscreen mode Exit fullscreen mode
# config/settings.py
import os

SIGNYU_API_KEY = os.environ["SIGNYU_API_KEY"]
SIGNYU_WEBHOOK_SECRET = os.environ["SIGNYU_WEBHOOK_SECRET"]
Enter fullscreen mode Exit fullscreen mode

Create a small module that builds the client once per process:

# contracts/signyu_client.py
from functools import lru_cache

from django.conf import settings
from signyu import SignYu


@lru_cache(maxsize=1)
def get_client() -> SignYu:
    return SignYu(api_key=settings.SIGNYU_API_KEY, timeout=60.0)
Enter fullscreen mode Exit fullscreen mode

2. A model to track documents

Store SignYu's documentId so webhooks can find the right row.

# contracts/models.py
from django.conf import settings
from django.db import models


class Contract(models.Model):
    class Status(models.TextChoices):
        PENDING = "PENDING"
        SENT = "SENT"
        COMPLETED = "COMPLETED"

    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    title = models.CharField(max_length=200)
    signyu_document_id = models.CharField(max_length=64, unique=True)
    status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING)
    signed_count = models.PositiveSmallIntegerField(default=0)
    completed_at = models.DateTimeField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self) -> str:
        return f"{self.title} ({self.status})"
Enter fullscreen mode Exit fullscreen mode
python manage.py makemigrations contracts && python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

3. Send a PDF for signature

Three API calls: create the document (multipart PDF upload, up to 10MB), add signers (up to 6), and send. Phone numbers must be digits only, at least 10.

# contracts/forms.py
from django import forms


class SendContractForm(forms.Form):
    title = forms.CharField(max_length=200)
    file = forms.FileField()
    signer_name = forms.CharField(max_length=120)
    signer_phone = forms.RegexField(regex=r"^\d{10,}$", help_text="Digits only")
    signer_email = forms.EmailField()

    def clean_file(self):
        f = self.cleaned_data["file"]
        if f.content_type != "application/pdf":
            raise forms.ValidationError("Upload a PDF.")
        if f.size > 10 * 1024 * 1024:
            raise forms.ValidationError("PDF must be 10MB or smaller.")
        return f
Enter fullscreen mode Exit fullscreen mode
# contracts/views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render
from signyu import SignYuError

from .forms import SendContractForm
from .models import Contract
from .signyu_client import get_client


@login_required
def send_contract(request):
    form = SendContractForm(request.POST or None, request.FILES or None)
    if request.method == "POST" and form.is_valid():
        data = form.cleaned_data
        upload = data["file"]
        client = get_client()

        try:
            doc = client.documents.create(
                file=upload.read(),
                file_name=upload.name,
                name=data["title"],
            )
            client.documents.add_signers(doc["documentId"], [{
                "name": data["signer_name"],
                "phone": data["signer_phone"],
                "email": data["signer_email"],
            }])
            # Deducts one credit per signer and emails each signer a link.
            sent = client.documents.send(doc["documentId"])
        except SignYuError as err:
            # err.code examples: insufficient_credits, invalid_request, file_too_large
            form.add_error(None, f"{err.message} ({err.code})")
        else:
            Contract.objects.create(
                owner=request.user,
                title=data["title"],
                signyu_document_id=sent["documentId"],
                status=Contract.Status.SENT,
            )
            return redirect("contracts:list")

    return render(request, "contracts/send.html", {"form": form})
Enter fullscreen mode Exit fullscreen mode

The template only needs enctype="multipart/form-data":

<!-- contracts/templates/contracts/send.html -->
<form method="post" enctype="multipart/form-data">
  {% csrf_token %}
  {{ form.as_p }}
  <button type="submit">Send for Aadhaar eSign</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Two things worth knowing:

  • Call send once per document. It charges credits for every signer. If a request times out, look the document up with client.documents.get(document_id) before you try again.
  • sent["signers"][i]["signUrl"] is the same link SignYu emails. You can also show it in your UI or send it over SMS or WhatsApp yourself.

For long uploads you may prefer to move these calls into a Celery or RQ task. The code is the same.

4. Verify and handle webhooks

SignYu posts two events to your endpoint:

Event When
signer.signed One signer finished
document.completed All signers finished

Each request has an X-SignSetu-Event header and an X-SignSetu-Signature header of the form sha256=<hex>, the HMAC-SHA256 of the raw request body keyed with your endpoint's signing secret. (The headers keep the product's former name, SignSetu.) In Django the raw bytes are request.body. Never verify against json.loads(...) re-serialized output.

Webhooks come from SignYu's servers, not a browser, so the view must be CSRF exempt. The HMAC check replaces CSRF protection.

# contracts/webhooks.py
import signyu
from django.conf import settings
from django.http import HttpResponse, HttpResponseBadRequest
from django.utils.dateparse import parse_datetime
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from .models import Contract


@csrf_exempt
@require_POST
def signyu_webhook(request):
    try:
        event = signyu.webhooks.construct_event(
            request.body,  # raw bytes
            request.headers.get("X-SignSetu-Signature"),
            settings.SIGNYU_WEBHOOK_SECRET,
        )
    except signyu.WebhookSignatureError:
        return HttpResponseBadRequest("Invalid signature")

    contract = Contract.objects.filter(signyu_document_id=event["documentId"]).first()
    if contract is None:
        # Not ours (or created outside this app). Acknowledge so it is not retried.
        return HttpResponse("ok")

    signed = sum(1 for s in event["signers"] if s["hasSigned"])

    if event["event"] == "signer.signed":
        # Idempotent: only move the counter forward.
        Contract.objects.filter(pk=contract.pk, signed_count__lt=signed).update(
            signed_count=signed
        )
    elif event["event"] == "document.completed":
        Contract.objects.filter(pk=contract.pk).update(
            status=Contract.Status.COMPLETED,
            signed_count=signed,
            completed_at=parse_datetime(event["completedAt"]) if event.get("completedAt") else None,
        )

    return HttpResponse("ok")
Enter fullscreen mode Exit fullscreen mode

If you would rather not depend on the SDK for this, the standard library is enough:

import hashlib
import hmac
from typing import Optional


def verify(raw_body: bytes, header: Optional[str], secret: str) -> bool:
    if not header:
        return False
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header)
Enter fullscreen mode Exit fullscreen mode

Wire up the URLs:

# contracts/urls.py
from django.urls import path

from . import views, webhooks

app_name = "contracts"

urlpatterns = [
    path("send/", views.send_contract, name="send"),
    path("<int:pk>/certificate/", views.certificate, name="certificate"),
    path("webhooks/signyu/", webhooks.signyu_webhook, name="signyu-webhook"),
]
Enter fullscreen mode Exit fullscreen mode

Then in the SignYu dashboard under Developers, add https://your-domain.com/contracts/webhooks/signyu/, copy the signing secret into SIGNYU_WEBHOOK_SECRET, and enable the endpoint. For local testing, run cloudflared tunnel --url http://localhost:8000 (or ngrok) and register that HTTPS URL. Add the tunnel host to ALLOWED_HOSTS.

Handler rules: return a 2xx quickly (non-2xx or a 10 second timeout triggers retries with backoff), and keep the handler idempotent, because an event can be delivered more than once.

5. Download the completion certificate

Once a contract is complete, the certificate endpoint returns a PDF with a Signature Completion Certificate and a chronological audit trail of the signing events. client.documents.get(id)["downloadUrl"] gives a short-lived link to the signed PDF itself.

# contracts/views.py (continued)
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404


@login_required
def certificate(request, pk):
    contract = get_object_or_404(Contract, pk=pk, owner=request.user)
    if contract.status != Contract.Status.COMPLETED:
        raise Http404("Not signed yet")

    pdf = get_client().documents.get_certificate(contract.signyu_document_id)
    response = HttpResponse(pdf, content_type="application/pdf")
    response["Content-Disposition"] = f'attachment; filename="certificate-{pk}.pdf"'
    return response
Enter fullscreen mode Exit fullscreen mode

Requesting it before completion returns 409 invalid_state, which the SDK raises as SignYuError.

Production checklist

  • API key only in environment variables or a secrets manager. If it leaks, delete it in the dashboard to revoke it immediately.
  • Gate send_contract behind login and your own permissions; it spends credits.
  • Show a clear message on 402 insufficient_credits.
  • Retry 429 and 5xx with backoff; do not retry other 4xx errors.
  • Drive status from webhooks, and reconcile occasionally with client.documents.list().

Wrapping up

That is a complete Django integration: a model, a send view, a verified webhook, and a certificate download. The full API reference (including custom signature placement per page) is in the SignYu docs, with an OpenAPI spec and a Postman collection. The Django guide is at signyu.com/aadhaar-esign-api/integrations/django, and if part of your stack is JavaScript, see the Next.js guide or my Next.js version of this tutorial. The Python SDK used here is open source at github.com/SignYu/signyu-python.

Disclosure: I work on SignYu (formerly SignSetu).

Top comments (0)