Offline Aadhaar KYC
2 endpoints · Core API
What is it?
Offline Aadhaar KYC performs a synchronous, no-redirect Aadhaar identity verification — your users never leave your app and there is no browser handoff. /init accepts either typed demographic fields, a photo of the Aadhaar card front, or both — if a field wasn't typed, OCR fills it in from the photo before verification runs. For most users with an existing linked account, the verified identity (and signed identity document, where available) comes back in the same call. A smaller share of users complete one additional one-time-password step via /verify-otp.
OCR is never itself the verification. Whatever ends up populated — typed or OCR-filled — is exactly what gets checked against Aadhaar by this same call, so a bad OCR read simply surfaces as a 422, never a false positive.
Quick Start
Option A: typed fields
POST /api/v1/aadhaar/offline-kyc/init
{
"aadhaarNumber": "999999999999",
"name": "RAM KUMAR",
"dob": "03071990",
"gender": "M",
"mobile": "9999999999"
}
Option B: photo instead of typing
POST /api/v1/aadhaar/offline-kyc/init
{
"mobile": "9999999999",
"aadhaarImage": "<base64 photo of the Aadhaar card front>"
}
Mix and match freely — type what you have, attach the photo for the rest; a typed field always wins over an OCR-read one for that same field.
aadhaarImage must be JPEG or PNG. A PDF (including a downloaded eAadhaar) or a HEIC photo straight from an iPhone is rejected with a 400 naming the format — the OCR engine's synchronous API reads only those two. Convert first, or type the details instead.
Field formats: dob is DDMMYYYY, day first — 03071990 is 3 July 1990, not 7 March. gender is a single character, M or F — no other value is accepted. aadhaarNumber is 12 digits; mobile is 10 digits. Both dob and gender are validated before the verification call, so a wrong format returns a 400 naming the problem rather than an ambiguous "did not match Aadhaar records".
Response (either option): transactionId, otpRequired, and — when otpRequired is false — the verified result inline, as 200 OK. When otpRequired is true instead, the call still succeeded but verification isn't finished — that comes back as 202 Accepted (not 200), with no result yet and message naming the mobile the OTP was sent to.
Step 2 (only if otpRequired is true): Verify the OTP
POST /api/v1/aadhaar/offline-kyc/verify-otp
{
"transactionId": "<from init>",
"otp": "123456"
}
Response: the verified result.
Result shape
{
"verifiedId": "stable pseudonymous identifier",
"name": "RAM KUMAR",
"dob": "03071990",
"gender": "M",
"mobile": "9999999999",
"verifiedAtUtc": "2026-08-21T10:18:30Z",
"document": { "data": "<UIDAI-signed eAadhaar XML>", "mime": "application/xml" },
"details": {
"name": "RAM KUMAR",
"dob": "03-07-1990",
"gender": "M",
"address": "12, MG Road, Near Park",
"village": "Pune",
"district": "Pune",
"state": "Maharashtra",
"pinCode": "411001",
"aadhaarNumber": "XXXXXXXX1234",
"photo": "<base64 JPEG>",
"kycDateTime": "25-08-2026/13:33:52"
},
"fieldsFilledFromImage": ["dob", "gender"]
}
details carries the demographic data that exists only inside the signed document — full address, photo, KYC timestamp. Its aadhaarNumber is masked to the last four digits; the unmasked number remains inside document.data only because UIDAI's signature covers it and stripping it would make the document unverifiable. details is null when the account has no eAadhaar on file — the identity is still verified.
verifiedId is a stable identifier for the verified individual — use it as your identity key. Never persist the Aadhaar number or photo you submitted; neither is echoed back and neither should be stored on your side. fieldsFilledFromImage lists which fields, if any, came from OCR rather than typing — empty when no photo was attached or nothing needed filling.
The document field is present only when a signed identity document is available for that account.
Error Codes
- 400 — Missing/invalid input, OCR couldn't read a required field from the photo (retype it and resubmit), or the verification session expired (restart from
/init) - 401 — Unauthorized: check X-API-KEY and X-API-APP-ID headers
- 404 — Signed identity document not available for this account (identity is still verified)
- 422 — Submitted (typed or OCR-filled) name/DOB/gender did not match Aadhaar records
- 429 — Too many requests
Benefits
- No redirect — your users stay on your screen throughout
- Often zero OTP — accounts with an existing link verify in a single call
- One consolidated result — identity and signed document in one response
- Type it or photograph it — one endpoint, either input, no separate OCR round trip
- Full UX ownership — your branding, your copy, your error handling
Start Aadhaar verification — from typed fields, a photo of the Aadhaar card front, or both.
Runs the Aadhaar demographic-authentication step against the user's linked identity account.
For most users — anyone with an existing linked account — the verified identity comes back
immediately in this same call, with otpRequired: false and result populated.
A smaller share of users (typically first-time/new accounts) get otpRequired: true
instead, with no result yet. In that case, collect a one-time password from the user
(delivered to their registered mobile) and call verify-otp with the transactionId
returned here — do not call init again for the same attempt. The response's
message differs by branch so you don't have to infer it from otpRequired alone:
on the OTP branch it names the mobile the OTP was sent to (e.g. "...OTP has been sent to
XXXXXX1234..."); on the immediate branch it confirms verification directly.
Two ways to supply aadhaarNumber/name/dob/gender:aadhaarImage (base64 photo/scan of the Aadhaar card front) and
leave some or all of those fields blank — the server runs OCR on the image and fills in
whatever wasn't typed. A field you did type always takes priority over what OCR reads for that
same field. JPEG or PNG only — a PDF (such as a downloaded eAadhaar) or a HEIC photo
straight from an iPhone is rejected with a 400 naming the format, because the OCR engine's
synchronous API reads only those two.mobile is always required and always typed — it isn't printed on the card, so OCR can
never supply it.
Field formats:dob is DDMMYYYY, day first — 03071990 is
3 July 1990, not 7 March. gender is a single character, M or F; no other
value is accepted, so a card reading TRANSGENDER cannot be verified through this API.
Both are validated before the verification call, so a wrong format comes back as a 400
naming the problem rather than an ambiguous "details did not match Aadhaar records".
OCR is never itself the verification. Whatever values end up populated — typed or OCR-filled — are exactly what get checked against Aadhaar by this same call. A bad OCR read therefore surfaces as a 422 ("did not match Aadhaar records"), never as a false positive, and costs you nothing more than the retry. If OCR genuinely can't locate a field on the image (a blurry photo, bad crop, wrong side of the card), you get a 400 asking you to supply that field manually and resubmit — no partial/guessed values are ever sent to verification.
The response's fieldsFilledFromImage (inside result) lists which fields, if any,
came from OCR rather than typing — useful if you want to show the user what was auto-filled.
The submitted Aadhaar number/image are used only for this call and are never persisted —
store the response's result.verifiedId as your identity key instead.
Where the Aadhaar details come from. The demographic profile returned by the
authentication step carries only name, DOB, gender and mobile. Full address, photo and the
KYC timestamp exist solely inside the signed identity document, so when that document is
available it is parsed into result.details. result.document.data holds the
signed XML verbatim for callers who want to verify the issuer signature themselves.
details.aadhaarNumber is masked to the last four digits; the unmasked number stays
only inside the signed document, because the signature covers it and altering it would make
the document unverifiable.
Headers
- X-API-KEYstringrequiredYour secret API key for the environment you are calling (Stage and Live use different keys).
- X-API-APP-IDstringrequiredThe App ID issued together with the key.
- Content-Typestringrequiredapplication/json
Body parameters
application/json- aadhaarNumberstringconditionalConditional: required unless API.Models.SignCare.ULIP.OfflineAadhaarKycInitRequest.AadhaarImage is supplied. 12-digit Aadhaar number, digits only. Held in memory only for this call — never persisted or logged.nullable
- namestringconditionalConditional: required unless API.Models.SignCare.ULIP.OfflineAadhaarKycInitRequest.AadhaarImage is supplied. Name exactly as on the Aadhaar record. Letters and spaces only — normalise dots/hyphens/apostrophes before sending.nullable
- dobstringconditionalConditional: required unless API.Models.SignCare.ULIP.OfflineAadhaarKycInitRequest.AadhaarImage is supplied. Date of birth in <b>DDMMYYYY</b> format, day first (e.g. 03071990 for 3 July 1990). No separators.nullable
- genderstringconditionalConditional: required unless API.Models.SignCare.ULIP.OfflineAadhaarKycInitRequest.AadhaarImage is supplied. Gender as a single character, M or F (either case). No other value is accepted.nullable
- mobilestringrequiredRequired. 10-digit mobile number registered against the Aadhaar. Always required — a mobile number is not printed on the card, so OCR can never supply this field.nullable
- aadhaarImagestringoptionalOptional. Base64-encoded photo/scan of the Aadhaar card front. When supplied, any of aadhaarNumber/name/dob/gender left blank are filled in via OCR before verification runs. Held in memory only for this call — never persisted or logged. <b>JPEG or PNG only.</b> PDF, HEIC (the iPhone camera default), TIFF, GIF and WebP are rejected with a 400 naming the format — the OCR engine's synchronous API reads JPEG and PNG bytes only. Convert first, or type the details instead.nullable
- request_idstringoptionalOptional. Accepted but ignored: the server generates its own request id for every call.nullable
Sample request body
Responses
200OK
- successbooleanoptionalNo description.
- statusCodeinteger (int32)optionalNo description.default 200
- messagestringoptionalNo description.nullabledefault XXXXX
- dataobjectoptionalWhen OtpRequired is false, Result is populated immediately — the identity was verified without an OTP, which is the common case. When OtpRequired is true, the caller must collect the OTP from the user and call the verify-otp endpoint with the same TransactionId; Result is null until then.
3 fields
- transactionIdstringoptionalNo description.nullable
- otpRequiredbooleanoptionalNo description.
- resultobjectoptionalNo description.
9 fields
- verifiedIdstringoptionalStable pseudonymous identifier — use this as the identity key, never the Aadhaar number.nullable
- namestringoptionalNo description.nullable
- dobstringoptionalNo description.nullable
- genderstringoptionalNo description.nullable
- mobilestringoptionalNo description.nullable
- verifiedAtUtcstring (date-time)optionalNo description.
- documentobjectoptionalNo description.
2 fields
- datastringoptionalThe UIDAI-signed eAadhaar KYC XML, verbatim as the issuer signed it. Returned unaltered on purpose: it is the tamper-evident artefact, and editing anything inside it (including the Aadhaar number) would break signature verification.nullable
- mimestringoptionalMedia type of API.Models.SignCare.ULIP.OfflineAadhaarKycDocument.Data — `application/xml` for the signed eAadhaar.nullable
- detailsobjectoptionalDemographic details read out of the signed identity document. Field-for-field identical on the wire to the details object the DigiLocker API returns, so a consumer integrating both maps one structure. It is a separate type purely so the published schema name stays provider-neutral — the OpenAPI document is customer-facing, and a schema called after a specific provider would name our upstream in the public contract.
15 fields
- namestringoptionalNo description.nullable
- dobstringoptionalDate of birth as printed on the document (DD-MM-YYYY).nullable
- genderstringoptionalNo description.nullable
- costringoptionalCare-of line, when the document carries one.nullable
- addressstringoptionalNo description.nullable
- villagestringoptionalNo description.nullable
- subDistrictstringoptionalNo description.nullable
- districtstringoptionalNo description.nullable
- statestringoptionalNo description.nullable
- countrystringoptionalNo description.nullable
- pinCodestringoptionalNo description.nullable
- aadhaarNumberstringoptionalMasked to the last four digits — the full number is never surfaced in a parsed field.nullable
- kycDateTimestringoptionalWhen the issuing authority performed the KYC that produced this document.nullable
- photostringoptionalBase64 JPEG portrait from the document, when present.nullable
- addressDetailsobjectoptionalThe address split into printable lines, as the issuer formats it.
3 fields
- addressLine1stringoptionalNo description.nullable
- addressLine2stringoptionalNo description.nullable
- addressLine3stringoptionalNo description.nullable
- fieldsFilledFromImagearray<string>optionalWhich fields, if any, were filled in from a submitted Aadhaar card-front photo rather than typed — e.g. ["name","dob"]. Empty when no image was supplied or nothing needed filling.nullable
- messagesarray<string>optionalNo description.nullable
- request_idstringoptionalNo description.nullable
- isBillablebooleanoptionalWhether this request was charged: true if it was, false if not. Errors always show false. Flows that finish later (ITR Fetch, SHCIL eStamp, DigiLocker, eSign) show false here, because the charge is made when the result is ready. eSign charges each signer when they complete signing. DigiLocker deducts the credit when the link is created and refunds it automatically if the link expires before the user completes it.
202Acceptedsame body as 200
400Bad Request
- dataobjectoptionalNo description.
- messagesarray<string>optionalNo description.nullable
- request_idstringoptionalNo description.nullable
- successbooleanoptionalNo description.default false
- messagestringoptionalNo description.nullabledefault XXXXX
- isBillablebooleanoptionalNo description.
- statusCodeinteger (int32)optionalNo description.default 400
401Unauthorized
- dataobjectoptionalNo description.
- messagesarray<string>optionalNo description.nullable
- request_idstringoptionalNo description.nullable
- successbooleanoptionalNo description.default false
- messagestringoptionalNo description.nullabledefault XXXXX
- isBillablebooleanoptionalNo description.
- statusCodeinteger (int32)optionalNo description.default 401
422Unprocessable Content
- successbooleanoptionalNo description.
- statusCodeinteger (int32)optionalNo description.default 200
- messagestringoptionalNo description.nullabledefault XXXXX
- dataanyoptionalNo description.nullable
- messagesarray<string>optionalNo description.nullable
- request_idstringoptionalNo description.nullable
- isBillablebooleanoptionalWhether this request was charged: true if it was, false if not. Errors always show false. Flows that finish later (ITR Fetch, SHCIL eStamp, DigiLocker, eSign) show false here, because the charge is made when the result is ready. eSign charges each signer when they complete signing. DigiLocker deducts the credit when the link is created and refunds it automatically if the link expires before the user completes it.
500Internal Server Error
- dataobjectoptionalNo description.
- messagesarray<string>optionalNo description.nullable
- request_idstringoptionalNo description.nullable
- successbooleanoptionalNo description.default false
- messagestringoptionalNo description.nullabledefault XXXXX
- isBillablebooleanoptionalNo description.
- statusCodeinteger (int32)optionalNo description.default 500
Code samplescurl · Node.js · Python
Complete verification for a new account by submitting the OTP sent to the user's registered mobile.
Only call this when a prior init call returned otpRequired: true. Use the same
transactionId from that response together with the OTP the user received.
The transaction has a short validity window — if it has lapsed, this returns 400 with a
message to restart from init; there is no separate resend endpoint, restarting from
init triggers a fresh OTP.
Headers
- X-API-KEYstringrequiredYour secret API key for the environment you are calling (Stage and Live use different keys).
- X-API-APP-IDstringrequiredThe App ID issued together with the key.
- Content-Typestringrequiredapplication/json
Body parameters
application/json- transactionIdstringrequiredRequired. The `transactionId` returned by the init call (only when it returned `otpRequired: true`). Valid for a short time only; after that, restart from init.nullable
- otpstringrequiredRequired. OTP delivered to the user's registered mobile (6 digits).nullable
Sample request body
Responses
200OK
- successbooleanoptionalNo description.
- statusCodeinteger (int32)optionalNo description.default 200
- messagestringoptionalNo description.nullabledefault XXXXX
- dataobjectoptionalNo description.
9 fields
- verifiedIdstringoptionalStable pseudonymous identifier — use this as the identity key, never the Aadhaar number.nullable
- namestringoptionalNo description.nullable
- dobstringoptionalNo description.nullable
- genderstringoptionalNo description.nullable
- mobilestringoptionalNo description.nullable
- verifiedAtUtcstring (date-time)optionalNo description.
- documentobjectoptionalNo description.
2 fields
- datastringoptionalThe UIDAI-signed eAadhaar KYC XML, verbatim as the issuer signed it. Returned unaltered on purpose: it is the tamper-evident artefact, and editing anything inside it (including the Aadhaar number) would break signature verification.nullable
- mimestringoptionalMedia type of API.Models.SignCare.ULIP.OfflineAadhaarKycDocument.Data — `application/xml` for the signed eAadhaar.nullable
- detailsobjectoptionalDemographic details read out of the signed identity document. Field-for-field identical on the wire to the details object the DigiLocker API returns, so a consumer integrating both maps one structure. It is a separate type purely so the published schema name stays provider-neutral — the OpenAPI document is customer-facing, and a schema called after a specific provider would name our upstream in the public contract.
15 fields
- namestringoptionalNo description.nullable
- dobstringoptionalDate of birth as printed on the document (DD-MM-YYYY).nullable
- genderstringoptionalNo description.nullable
- costringoptionalCare-of line, when the document carries one.nullable
- addressstringoptionalNo description.nullable
- villagestringoptionalNo description.nullable
- subDistrictstringoptionalNo description.nullable
- districtstringoptionalNo description.nullable
- statestringoptionalNo description.nullable
- countrystringoptionalNo description.nullable
- pinCodestringoptionalNo description.nullable
- aadhaarNumberstringoptionalMasked to the last four digits — the full number is never surfaced in a parsed field.nullable
- kycDateTimestringoptionalWhen the issuing authority performed the KYC that produced this document.nullable
- photostringoptionalBase64 JPEG portrait from the document, when present.nullable
- addressDetailsobjectoptionalThe address split into printable lines, as the issuer formats it.
3 fields
- addressLine1stringoptionalNo description.nullable
- addressLine2stringoptionalNo description.nullable
- addressLine3stringoptionalNo description.nullable
- fieldsFilledFromImagearray<string>optionalWhich fields, if any, were filled in from a submitted Aadhaar card-front photo rather than typed — e.g. ["name","dob"]. Empty when no image was supplied or nothing needed filling.nullable
- messagesarray<string>optionalNo description.nullable
- request_idstringoptionalNo description.nullable
- isBillablebooleanoptionalWhether this request was charged: true if it was, false if not. Errors always show false. Flows that finish later (ITR Fetch, SHCIL eStamp, DigiLocker, eSign) show false here, because the charge is made when the result is ready. eSign charges each signer when they complete signing. DigiLocker deducts the credit when the link is created and refunds it automatically if the link expires before the user completes it.
400Bad Request
- dataobjectoptionalNo description.
- messagesarray<string>optionalNo description.nullable
- request_idstringoptionalNo description.nullable
- successbooleanoptionalNo description.default false
- messagestringoptionalNo description.nullabledefault XXXXX
- isBillablebooleanoptionalNo description.
- statusCodeinteger (int32)optionalNo description.default 400
401Unauthorized
- dataobjectoptionalNo description.
- messagesarray<string>optionalNo description.nullable
- request_idstringoptionalNo description.nullable
- successbooleanoptionalNo description.default false
- messagestringoptionalNo description.nullabledefault XXXXX
- isBillablebooleanoptionalNo description.
- statusCodeinteger (int32)optionalNo description.default 401
500Internal Server Error
- dataobjectoptionalNo description.
- messagesarray<string>optionalNo description.nullable
- request_idstringoptionalNo description.nullable
- successbooleanoptionalNo description.default false
- messagestringoptionalNo description.nullabledefault XXXXX
- isBillablebooleanoptionalNo description.
- statusCodeinteger (int32)optionalNo description.default 500