If you are building for Indian users, sooner or later someone asks for "Aadhaar eSign": the signer gets an OTP on their Aadhaar-linked phone, signs the PDF, and the result is a valid electronic signature under Section 3A of the IT Act 2000. Rental agreements, offer letters, loan documents, vendor contracts: they all need it.
Doing it directly means working with an eSign provider's gateway, envelope encryption, and a redirect based signing flow. In this tutorial we skip all of that and use the SignYu API, which wraps the provider (eMudhra) behind a small REST API. We will build:
- A route handler that uploads a PDF, adds signers, and sends it for signature.
- A webhook route that verifies SignYu's HMAC signature and updates your app when someone signs.
- A route that streams the signed PDF's completion certificate.
Everything runs server side in the Next.js App Router. A full Next.js integration guide also lives at signyu.com/aadhaar-esign-api/integrations/nextjs.
What you need
- A Next.js 14+ app using the App Router (this was written against Next.js 16).
- Node.js 18 or newer (the SDK uses the global
fetch). - A SignYu account with API access. API access is ₹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 a key under Developers at https://signyu.com/app/developers.
There is no sandbox: every signature is a real Aadhaar eSign. Test with your own phone and keep a few credits for development.
1. Install and configure
npm install signyu
Add your secrets to .env.local (never commit them, and never prefix them with NEXT_PUBLIC_):
SIGNYU_API_KEY=sk_live_xxxxxxxxxxxxxxxx
SIGNYU_WEBHOOK_SECRET=your_endpoint_signing_secret
Create one shared client. The server-only import makes the build fail if this file is ever pulled into a client component, which keeps your key out of the browser bundle.
// lib/signyu.ts
import "server-only";
import { SignYu } from "signyu";
const apiKey = process.env.SIGNYU_API_KEY;
if (!apiKey) throw new Error("SIGNYU_API_KEY is not set");
export const signyu = new SignYu({ apiKey });
2. Send a PDF for signature
The flow is three calls: create a document (multipart PDF upload), add signers, send. You can add up to 6 signers per document, and the PDF can be up to 10MB. Phone numbers must be digits only, at least 10.
// app/api/contracts/route.ts
import { NextResponse } from "next/server";
import { SignYuError } from "signyu";
import { signyu } from "@/lib/signyu";
export const runtime = "nodejs";
export async function POST(req: Request) {
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File) || file.type !== "application/pdf") {
return NextResponse.json({ error: "Upload a PDF file" }, { status: 400 });
}
const signer = {
name: String(form.get("name") ?? ""),
phone: String(form.get("phone") ?? "").replace(/\D/g, ""),
email: String(form.get("email") ?? ""),
};
try {
// 1. Upload the PDF. A File is a Blob, which the SDK accepts directly.
const doc = await signyu.documents.create({
file,
fileName: file.name,
name: String(form.get("title") ?? file.name),
});
// 2. Add one or more signers (up to 6).
await signyu.documents.addSigners(doc.documentId, [signer]);
// 3. Send. This deducts one credit per signer and emails each a link.
const sent = await signyu.documents.send(doc.documentId);
// Save doc.documentId against your own record here (DB call omitted).
return NextResponse.json({
documentId: sent.documentId,
status: sent.status, // "SENT"
signUrl: sent.signers[0]?.signUrl,
});
} catch (err) {
if (err instanceof SignYuError) {
// e.g. 402 insufficient_credits, 400 invalid_request, 413 file_too_large
return NextResponse.json(
{ error: err.code, message: err.message },
{ status: err.status || 502 }
);
}
throw err;
}
}
A few notes from production use:
-
Call
sendonce per document. It charges credits for every signer. If your request times out, check the document withsignyu.documents.get(id)before trying again, rather than blindly retrying. -
signUrlis the same link SignYu emails to the signer. You can show it in your UI (for example "Sign now" when the signer is the logged in user) or send it over WhatsApp yourself. - Errors come back as
SignYuErrorwithstatus,code, andmessage. Usecodefor logic andmessagefor logs.
A minimal form to call it:
// app/contracts/new/page.tsx
export default function NewContract() {
return (
<form action="/api/contracts" method="post" encType="multipart/form-data">
<input name="title" placeholder="Document title" />
<input name="file" type="file" accept="application/pdf" required />
<input name="name" placeholder="Signer name" required />
<input name="phone" placeholder="Signer mobile" required />
<input name="email" type="email" placeholder="Signer email" required />
<button type="submit">Send for Aadhaar eSign</button>
</form>
);
}
(In a real app, call this route from a server action or fetch and protect it with your own auth, since anyone who can hit it can spend your credits.)
3. Receive webhooks
Polling GET /documents/{id} works, but webhooks are nicer. SignYu sends two events:
| Event | When |
|---|---|
signer.signed |
One signer finished signing |
document.completed |
Every signer has signed |
Each delivery is a JSON POST with two headers: X-SignSetu-Event (the event name) and X-SignSetu-Signature, which is sha256= followed by a hex HMAC-SHA256 of the raw request body, keyed with your endpoint's signing secret. (The headers keep the product's former name, SignSetu, for compatibility.)
The important part in Next.js: read the raw bytes before parsing. If you call req.json() and re-serialize, the bytes can differ and the signature will not match.
// app/api/webhooks/signyu/route.ts
import { constructEvent, WebhookSignatureError } from "signyu";
export const runtime = "nodejs";
export async function POST(req: Request) {
const rawBody = new Uint8Array(await req.arrayBuffer());
const signature = req.headers.get("x-signsetu-signature");
let event;
try {
event = constructEvent(rawBody, signature, process.env.SIGNYU_WEBHOOK_SECRET!);
} catch (err) {
if (err instanceof WebhookSignatureError) {
return new Response("Invalid signature", { status: 400 });
}
throw err;
}
switch (event.event) {
case "signer.signed":
// event.signer is who just signed; event.signers has everyone's progress
console.log(`${event.signer?.name ?? "A signer"} signed ${event.documentId}`);
break;
case "document.completed":
// Mark your record complete. event.certificateUrl is Bearer-authenticated.
console.log(`Document ${event.documentId} completed at ${event.completedAt}`);
break;
}
return new Response("ok", { status: 200 });
}
If you prefer no dependency here, verification is a few lines of Node crypto:
import crypto from "node:crypto";
function verify(rawBody: Buffer, header: string | null, secret: string) {
if (!header) return false;
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(header);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Then register the endpoint: in the SignYu dashboard under Developers, add https://your-app.com/api/webhooks/signyu, copy the signing secret into SIGNYU_WEBHOOK_SECRET, and enable it. For local development, expose localhost:3000 with a tunnel such as cloudflared tunnel --url http://localhost:3000 or ngrok and register that HTTPS URL.
Two rules for the handler:
- Return 2xx quickly. Non-2xx responses or timeouts (10 seconds) are retried with backoff.
-
Be idempotent. The same event can arrive more than once. Use
documentIdplus event name (andsigner.signerIdforsigner.signed) as a dedupe key.
4. Download the signed PDF and the certificate
Once a document is COMPLETED, get returns a temporary presigned downloadUrl for the signed PDF. The certificate endpoint returns a PDF with a Signature Completion Certificate and a chronological audit trail of the signing events.
// app/api/contracts/[id]/certificate/route.ts
import { signyu } from "@/lib/signyu";
export const runtime = "nodejs";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// Check that the logged in user owns this document before this line.
const pdf = await signyu.documents.getCertificate(id);
return new Response(pdf, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="certificate-${id}.pdf"`,
},
});
}
// Signed PDF: redirect to the short-lived presigned URL
const doc = await signyu.documents.get(id);
if (doc.status === "COMPLETED" && doc.downloadUrl) {
return Response.redirect(doc.downloadUrl, 302);
}
Calling the certificate endpoint before completion returns 409 invalid_state.
Production checklist
- Keep
SIGNYU_API_KEYserver side only. If it leaks, delete it in the dashboard; that revokes it immediately. - Protect your
/api/contractsroute with your own auth and rate limiting. - Handle
402 insufficient_creditswith a clear message to whoever manages billing. - Retry
429and5xxwith backoff. Do not retry other4xxerrors. - Store
documentIdon your side and drive UI state from webhooks, not from the browser redirect.
Wrapping up
That is the whole integration: one route to send, one to receive webhooks, one to download. The full reference (signature placement, list endpoints, error codes) is in the SignYu docs, there is an OpenAPI spec and a Postman collection, and the Next.js specific guide is at signyu.com/aadhaar-esign-api/integrations/nextjs.
Using Django instead? See signyu.com/aadhaar-esign-api/integrations/django.
Disclosure: I work on SignYu (formerly SignSetu).
Top comments (1)
Really well-explained tutorial! 👏 Simplifying Aadhaar eSign integration with Next.js into a step-by-step workflow makes it much more accessible for developers. The focus on webhooks, security, and production considerations is especially valuable. Great work! 🚀