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:
- A
Contractmodel that tracks each document. - A view that uploads a PDF, adds signers, and sends it.
- A webhook view that verifies the HMAC signature and updates the model.
- 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
Keep secrets in environment variables, not in settings.py:
export SIGNYU_API_KEY=sk_live_xxxxxxxxxxxxxxxx
export SIGNYU_WEBHOOK_SECRET=your_endpoint_signing_secret
# config/settings.py
import os
SIGNYU_API_KEY = os.environ["SIGNYU_API_KEY"]
SIGNYU_WEBHOOK_SECRET = os.environ["SIGNYU_WEBHOOK_SECRET"]
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)
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})"
python manage.py makemigrations contracts && python manage.py migrate
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
# 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})
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>
Two things worth knowing:
-
Call
sendonce per document. It charges credits for every signer. If a request times out, look the document up withclient.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")
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)
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"),
]
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
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_contractbehind login and your own permissions; it spends credits. - Show a clear message on
402 insufficient_credits. - Retry
429and5xxwith backoff; do not retry other4xxerrors. - 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)