AEPS — Aadhaar Enabled Payment System
AEPS lets a merchant's agent perform Aadhaar/biometric-authenticated banking transactions — cash withdrawal, balance enquiry, mini statement, Aadhaar Pay — for a customer at the point of sale, without a card or PIN.
This page was rewritten against the live backend
The previous version of this page used operation names (aepsCashWithdrawal, aepsMerchantAuth, …) and fields (aadhaarNumber, bankIin, pidData) that do not match paymentSystem's actual AEPS module. What follows is read directly from src/modules/interface/nsdl/aeps/aeps.route.js, aeps.controller.js, aeps.txn.controller.js, and aeps.txn.service.js — including the sandbox fixtures in simulate.js, which is what you'll see running against a AEPS_SIMULATE=true deployment.
Two ways to call this
| Base | Auth | Body | |
|---|---|---|---|
| Merchant — direct | paymentSystem — POST /aeps/* | Authorization: Bearer <JWT> (see Merchant API Authentication) | Plain JSON, unencrypted |
| Vendor — via gateway | fintech-gateway — POST /process/aeps/* | X-Client-Id + AES-256-GCM/HMAC envelope (see Partner API Security) | Same plain JSON as the direct call, just encrypted — plus a merchantId field |
fintech-gateway's /process/* route is a 1:1 path mirror, not an operation map — POST /process/aeps/withdrawal decrypts to and forwards exactly what POST /aeps/withdrawal would receive directly. The field names on this page apply to both paths identically; the only addition on the vendor side is merchantId in the body (see Access control below and Vendor AEPS).
/aeps/process is not live
An older gateway design (fintech-gateway/src/routes/aeps.js, an { "operation": "<name>", ...fields } envelope over POST /aeps/process) exists in the fintech-gateway codebase but is never registered in src/app.js — only the /process/* wildcard proxy above is actually mounted. If you've seen operation names or /aeps/process referenced elsewhere, that describes a design that isn't currently reachable.
Onboarding
One-time device/agent onboarding, in order. Every endpoint below is POST /aeps/<route> direct (merchant JWT) or POST /process/aeps/<route> via the gateway (vendor — add merchantId to the body; see Vendor AEPS). Request bodies below are the merchant/direct form; a vendor call is the same body plus merchantId.
Response shapes below
Where a response is paymentSystem's own construction (not NSDL passthrough), it's shown exactly as the controller builds it — verified against aeps.controller.js. Where the data payload is NSDL's own response forwarded as-is, that's called out explicitly; capture a real response in your environment rather than assuming the shown shape is complete.
initialize
POST /aeps/initialize
Authorization: Bearer <token>
No body. Creates the AEPS Service-KYC record for this merchant if one doesn't already exist.
{ "merchantId": "m-uuid", "service": "AEPS" }
Note the shape: this specific route does not wrap its response in the usual {statusCode, message, data} envelope — it returns merchantId and service directly at the top level on success (verified in aeps.controller.js#initialize). On failure it falls back to the standard envelope, e.g. { "statusCode": "500", "message": "Server error" }.
onboarding
POST /aeps/onboarding
Authorization: Bearer <token>
No body. Requires merchant.merchantinfo to already exist. Triggers NSDL onboarding and auto-requests the first OTP.
{
"statusCode": "00",
"otp_status": true,
"otp_reqid": "OTPREQ-...",
"...": "additional fields are NSDL's own onboarding response, passed through — not independently confirmed here"
}
Failure modes returned directly (not via NSDL): { "statusCode": "01", "message": "Merchant not found" } and { "statusCode": "01", "message": "Merchant info not created" }.
request_otp
POST /aeps/request_otp
Authorization: Bearer <token>
No body. Re-sends the onboarding OTP. Response is NSDL's request_otp reply, passed through — expect at least an otpreqid-shaped field on success; capture a real response to confirm the exact key names.
validate_otp
POST /aeps/validate_otp
Authorization: Bearer <token>
Content-Type: application/json
{ "otp": "123456", "otpreqid": "OTPREQ-..." }
Both fields required — otpreqid comes from onboarding/request_otp's response. Response is NSDL's validate_otp reply, passed through (statusCode: "00" on success is the one field this page confirms; the rest of data isn't independently captured here).
verify_pan
POST /aeps/verify_pan
Authorization: Bearer <token>
Content-Type: application/json
{ "pan": "ABCDE1234F", "name": "Ramesh Kumar", "dob": "1990-01-15" }
Response is NSDL's PAN-verification reply, passed through — not independently confirmed field-by-field here.
update_UID
POST /aeps/update_UID
Authorization: Bearer <token>
Content-Type: application/json
{ "aadhaarno": "999999999999" }
Response is NSDL's UID-update reply, passed through.
updatebiodetail
POST /aeps/updatebiodetail
Authorization: Bearer <token>
Content-Type: application/json
{ "PidData": "<PidData XML from the RD service>", "aadhaarno": "999999999999" }
Response is NSDL's biometric-update reply, passed through.
submit
POST /aeps/submit
Authorization: Bearer <token>
No body — uses the merchant's own appid as the NSDL reference. Finalizes onboarding.
{ "statusCode": "00", "message": "...", "data": { "...": "NSDL confirmation response, passed through" } }
statusCheck
GET /aeps/statusCheck
Authorization: Bearer <token>
No body. Response is NSDL's status-check reply for this merchant's onboarding, passed through.
GetAgentDetail
POST /aeps/GetAgentDetail
Authorization: Bearer <token>
Content-Type: application/json
{ "agentId": "" }
agentId optional — omit it (or send an empty string) and it defaults to your own terminal's mid. Response is NSDL's agent-detail reply, passed through.
Transactions
All five transaction operations share one request shape and one response envelope — differing only in which data fields NSDL actually populates. Every endpoint is POST /aeps/<route> direct (merchant JWT) or POST /process/aeps/<route> via the gateway (vendor — add merchantId).
| Field | Required | Notes |
|---|---|---|
AadharNo | Yes | Customer's Aadhaar number |
TxnAmount | Yes, always | Required by the validator even for balance/minstatement/reqAuth — send 0 |
Latitude, Longitude | Yes | Agent's device location |
CustomerName | Yes | |
PidData | Yes | Raw PidData XML captured from a UIDAI-certified RD service app (in.gov.uidai.rdservice.fp.CAPTURE intent on Android — vendor-agnostic; Mantra, Morpho, Startek, SecuGen all implement the same intent contract) |
BankName | No | The receiving bank's IIN. Omit it and paymentSystem defaults to the sandbox test code 990326 — fine for AEPS_SIMULATE=true, not a real bank in production |
Cash Deposit is not implemented yet
The checksum layer has a CD (Cash Deposit) transaction-type constant defined, but there's no /aeps/* route or controller function behind it — don't build against it as if it exists. If you need it, that's a paymentSystem change to request, not something to work around client-side.
withdrawal
POST /aeps/withdrawal
Authorization: Bearer <token>
Content-Type: application/json
{
"AadharNo": "999999999999",
"TxnAmount": 500,
"Latitude": "19.0760",
"Longitude": "72.8777",
"CustomerName": "Ramesh Kumar",
"PidData": "<PidData XML from the RD service>",
"BankName": "918152"
}
{
"statusCode": "00",
"message": "Transaction successful",
"data": {
"ResponseCode": "00",
"ResponseStatus": "SUCCESS",
"Balance": "15230.00",
"STAN": "011032",
"RetailerReferenceNumber": "RRN-...",
"ReferenceNo": "...",
"AuthCode": "cdcf3f268b87422f859e0b10676bfde8",
"AadharNo": "********4821"
}
}
Balance here is the balance after the withdrawal.
balance
POST /aeps/balance
Content-Type: application/json
{
"AadharNo": "999999999999",
"TxnAmount": 0,
"Latitude": "19.0760",
"Longitude": "72.8777",
"CustomerName": "Ramesh Kumar",
"PidData": "<PidData XML from the RD service>",
"BankName": "918152"
}
{
"statusCode": "00",
"message": "Transaction successful",
"data": {
"ResponseCode": "00",
"ResponseStatus": "SUCCESS",
"Balance": "15230.00",
"STAN": "011033",
"RetailerReferenceNumber": "RRN-...",
"AadharNo": "********4821"
}
}
minstatement
POST /aeps/minstatement
Content-Type: application/json
{
"AadharNo": "999999999999",
"TxnAmount": 0,
"Latitude": "19.0760",
"Longitude": "72.8777",
"CustomerName": "Ramesh Kumar",
"PidData": "<PidData XML from the RD service>",
"BankName": "918152"
}
{
"statusCode": "00",
"message": "Transaction successful",
"data": {
"ResponseCode": "00",
"ResponseStatus": "SUCCESS",
"Ministatement": "02/06 POS/D/5017000018 C 100.00\n01/06 POS/W/5017000017 D 500.00",
"STAN": "011034",
"AadharNo": "********4821"
}
}
Ministatement is a single delimited string (one line per transaction, \n-separated), not a JSON array — NSDL's own format, passed through as-is.
reqAuth
POST /aeps/reqAuth
Content-Type: application/json
{
"AadharNo": "999999999999",
"TxnAmount": 0,
"Latitude": "19.0760",
"Longitude": "72.8777",
"CustomerName": "Ramesh Kumar",
"PidData": "<PidData XML from the RD service>",
"BankName": "918152"
}
{
"statusCode": "00",
"message": "Transaction successful",
"data": {
"ResponseCode": "00",
"ResponseStatus": "SUCCESS",
"STAN": "011030"
}
}
A successful reqAuth caches the session in Redis for 24 hours — see Session reuse below. There's no Balance or Ministatement field on this one; it's purely an authentication handshake.
purchase
POST /aeps/purchase
Content-Type: application/json
{
"AadharNo": "999999999999",
"TxnAmount": 350,
"Latitude": "19.0760",
"Longitude": "72.8777",
"CustomerName": "Ramesh Kumar",
"PidData": "<PidData XML from the RD service>",
"BankName": "918152"
}
{
"statusCode": "00",
"message": "Transaction successful",
"data": {
"ResponseCode": "00",
"ResponseStatus": "SUCCESS",
"STAN": "011035",
"RetailerReferenceNumber": "RRN-...",
"ReferenceNo": "...",
"AuthCode": "9f1c2a3b4d5e6f708192a3b4c5d6e7f8",
"AadharNo": "********4821"
}
}
Same response shape as withdrawal, minus Balance — this is a debit at the point of sale ("Aadhaar Pay"), not a cash disbursement, so there's no "balance after" to report.
Common response notes
ResponseCode "00"→ success. Anything else → failed.- No response at all (NSDL network-level timeout) is a real, distinct outcome
paymentSystemtreats as pending, not failed — it still records the attempt with a generated reference (AEPS-NR-<timestamp>) so it can be reconciled later. Handle "no data" as pending in your own client too, rather than assuming a timeout means the money didn't move.
reqAuth and session reuse
NSDL requires a fresh authenticated session once per day per terminal. paymentSystem handles this for you automatically — if withdrawal/balance/ minStatement/purchase finds no active session in Redis for your terminal, it runs reqAuth inline before your transaction, then caches the session for 24 hours. Calling reqAuth explicitly first is optional, not required.
Access control
- Every route above accepts both roles (
checkRole("merchant", "vendor")) — a merchant's JWT or a vendor's gateway-authenticated call both work. - A vendor acting on behalf of a merchant must include
merchantIdin the body —paymentSystemresolves the merchant asreq.user?.merchantId ?? req.body.merchantId. A merchant's JWT already carriesmerchantId, so the body field is what a vendor call uses instead. See the Vendor AEPS page for the vendor-specific shape.
RD Service Integration
PidData comes from a locally-running, UIDAI-certified RD service — the same standardized Intent contract on Android regardless of vendor:
| Action | Purpose |
|---|---|
in.gov.uidai.rdservice.fp.INFO | Detect the device, read DEVICE_INFO/RD_SERVICE_INFO extras |
in.gov.uidai.rdservice.fp.CAPTURE | Capture a fingerprint; pass PID_OPTIONS XML in, read PID_DATA XML out |
Never persist PidData or Aadhaar in full — capture, use once, discard. Mask Aadhaar in any UI/logs (paymentSystem itself already masks it in AadharNo on the way back, per simulate.js's maskAadhar()).
Supported Biometric (RD Service) Devices
Because the Intent contract above is UIDAI's own L1 spec, any UIDAI-certified RD service device should work — the platform doesn't hardcode a device allowlist. In practice, one vendor has been built against and verified end to end; the rest are named in the codebase but not yet confirmed against real hardware.
| Vendor | Status | Notes |
|---|---|---|
| Mantra | ✅ Verified | Fingerprint (MFS100) confirmed working end to end, including through onboarding (Onboard.vue). RD service on 127.0.0.1:11100. Iris capture defaults to DeviceMake: "MANTRAIRIS" server-side (aeps.txn.service.js) when not overridden. The vendor's own SDK (Android_RD_SDK_Sample, Java, and Web packages) ships in this repo under MantraSDK/. |
| Morpho / Idemia | ⚠️ Listed, not verified | Referenced in android-sdk's RD-vendor list and webpayment's device config as a placeholder port (11101) — explicitly commented "not verified against a live device." |
| Startek | ⚠️ Listed, not verified | Same as above — placeholder port 11103, unverified. |
| SecuGen | ⚠️ Listed, not verified | Same as above — placeholder port 11102, unverified. |
| Precision, Evolute | ⚠️ Named only | Appear only in a source comment listing UIDAI-certified vendors — no port, no integration code, no testing evidence anywhere in the codebase. |
What "verified" means here
"Verified" = there's working code and/or a confirmed local RD-service port exercised against real hardware somewhere in this repo. "Listed, not verified" = the vendor is named (usually as a config placeholder or a generic error-message suggestion like "install Mantra/Morpho/Startek/ SecuGen's RD service app") but nothing in the codebase has actually exercised that vendor's device. Since they all implement the same UIDAI Intent contract, they're expected to work — "not verified" is a testing gap here, not a known incompatibility. Confirm on real hardware before relying on a non-Mantra device in production.
Every RD service (any vendor) listens locally and exposes the same two actions over HTTP or Android Intent — INFO to detect the device and CAPTURE to run a capture and return PidData XML. Point your integration at whichever local port your installed RD service app uses; Mantra's default is 11100.
Transaction Flow
One-time setup (initialize → onboarding → request_otp → validate_otp → verify_pan → update_UID → updatebiodetail → submit) only runs again if you're re-onboarding the merchant — daily transacting only touches the right-hand branch above. balance/minstatement are typically called before withdrawal/purchase to confirm the account, but nothing enforces that ordering server-side.