Getting Started
All API requests require an API key in the request header.
Required Headers
X-Api-Key: your_api_key Content-Type: application/json
Your server IP must be whitelisted before making API calls — this is mandatory, not optional. An account with no whitelist configured blocks every request. Submit your IP and callback URL via the dashboard. Admin approval required.
Authentication Flow
You also need: an active, KYC-verified account, a commission package that includes the service you're calling, and explicit service access granted to your account for each service. Any one of these missing will block the relevant calls even with a valid key.
What's Available
| Service | What it does | Account model |
|---|---|---|
| PayIn | Collect UPI payments from your customers | Single wallet — your account only |
| Payout | Send money to any bank account via IMPS | Single wallet — your account only |
| DMT | Domestic money transfer on behalf of a "remitter" (your end-customer) | Single wallet — remitters/beneficiaries tracked by mobile number |
| Merchant Onboarding + AEPS | Aadhaar-enabled cash withdrawal/balance/mini-statement at your retail outlets | Multi-outlet — one API key, many outlet_ids (see below)
|
- PayIn / Payout / DMT — one API key = one wallet = your account.
There's no sub-account concept; if you serve multiple downstream customers, tracking
which transaction belongs to whom is your own responsibility (use
order_id/partner_request_id). - AEPS / Merchant Onboarding — one API key can serve many
outlets (your retailers). Each retailer onboards with their own
Aadhaar/PAN, gets an
outlet_id, and every AEPS call after that requires you to pass that specificoutlet_id. See Merchant Onboarding for the full explanation.
PayIn Live
Collect UPI payments from your customers. Supports GPay, PhonePe, Paytm, BHIM and all UPI apps.
unsettled_balance (not your spendable balance — see the Wallet section for why).Expiry: Payment link expires in 10 minutes. Unpaid transactions are auto-marked as
expired.
POST /api/v1/payin/create
▶
Request Body
{
"order_id": "YOUR-ORDER-001",
"amount": 1000,
"customer_phone": "9876543210",
"customer_name": "Rahul Sharma"
}
| Field | Type | Required | Description |
|---|---|---|---|
order_id |
string | Optional | Your unique order ID (max 100 chars, truncated to 35 chars when forwarded to the payment provider — keep it ≤35 chars). Strongly recommended: without it, retried/duplicate requests create separate PayIn links instead of being blocked. |
amount |
number | Required | Collection amount in ₹ — minimum ₹300, maximum ₹1,00,000 |
customer_phone |
string | Required | Customer's 10-digit mobile number |
customer_name |
string | Optional | Customer name (max 100 chars, truncated to 20 chars for the provider) |
Response — 201
{
"success": true,
"data": {
"transaction_id": "8a7f3e2d-9c1b-4f5a-b8e6-d4c2a1f9e7b3",
"custom_txn_id": "KRESTAA9MKMEQ5LMV29C2",
"order_id": "YOUR-ORDER-001",
"txn_ref": "PAYIN-REF-XXXXX",
"amount": 1000.00,
"commission": 10.00,
"intent_url": "upi://pay?pa=xxxxx&am=1000.00&cu=INR",
"expires_in": 600,
"status": "pending"
}
}
transaction_id: in this create response
it's the internal ID. Everywhere else — status checks, transaction history,
webhooks — the transaction_id field instead returns the human-readable
custom_txn_id (e.g. KRESTAA9MKMEQ5LMV29C2). To avoid
mismatches, always store and match on custom_txn_id — it's present in
every response and never changes meaning. Use txn_ref (not
transaction_id) for status checks below.
| Field | Description |
|---|---|
custom_txn_id |
Your stable reference for this transaction — use this everywhere |
txn_ref |
Save this — required for the status-check call below |
intent_url |
UPI payment link — generate QR or redirect mobile users |
expires_in |
Seconds until expiry (600 = 10 minutes) |
commission |
Your service charge — deducted from credited amount |
Showing the Payment to Customer
<script src="https://cdnjs.cloudflare.com/ajax/libs/
qrcodejs/1.0.0/qrcode.min.js"></script>
<div id="qrcode"></div>
<input type="hidden" id="upi-url"
value="{{ $intentUrl }}">
<script>
new QRCode(document.getElementById('qrcode'), {
text: document.getElementById('upi-url').value,
width: 220, height: 220
});
</script>
<!-- Pass via hidden input to avoid
HTML encoding issues -->
<input type="hidden" id="upi-url"
value="{{ $intentUrl }}">
<button onclick="
window.location.href =
document.getElementById('upi-url').value
">
Pay with UPI App
</button>
GET /api/v1/payin/status/{txn_ref}
▶
If the transaction is already in a terminal state
(success/failed/expired), this returns the
stored record instantly. Otherwise it actively re-checks with the payment provider
first — safe to use as a polling fallback if you haven't set up webhooks yet.
{
"success": true,
"data": {
"transaction_id": "KRESTAA9MKMEQ5LMV29C2",
"internal_txn_id": "8a7f3e2d-9c1b-4f5a-b8e6-d4c2a1f9e7b3",
"custom_txn_id": "KRESTAA9MKMEQ5LMV29C2",
"order_id": "YOUR-ORDER-001",
"txn_ref": "PAYIN-REF-XXXXX",
"amount": 1000.00,
"commission": 10.00,
"utr": "407241812279",
"status": "success",
"created_at": "2026-04-30T10:00:00.000000Z"
}
}
| Status | Meaning |
|---|---|
pending |
Awaiting customer payment |
success |
Payment received — credited to your unsettled_balance |
failed |
Payment failed |
expired |
10 minutes passed without payment |
GET /api/v1/payin/transactions
▶
| Param | Description | Default |
|---|---|---|
page |
Page number | 1 |
limit |
Records per page (max 100) | 15 |
Payout Live
Transfer money directly to any bank account via IMPS.
available_balanceRefund: If a transfer doesn't go through, the full amount is automatically refunded — whether it comes back to you tagged
refunded or failed,
treat both the same way: money back in your wallet, transfer did not happen.
POST /api/v1/payout/process
▶
Request Body
{
"mobile_no": "9876543210",
"beneficiary_name": "Rahul Sharma",
"account_no": "1234567890",
"ifsc": "SBIN0001234",
"bank_name": "State Bank of India",
"amount": 5000,
"partner_request_id": "YOUR-UNIQUE-ORDER-ID"
}
Note: transfers are always IMPS — there's no
transfer_type field to send; it's handled automatically on our
side.
| Field | Type | Required | Description |
|---|---|---|---|
mobile_no |
string | Required | 10-digit mobile number |
beneficiary_name |
string | Required | Account holder name (max 100 chars) |
account_no |
string | Required | Bank account number (8–20 characters) |
ifsc |
string | Required | IFSC code — format: ABCD0123456. Must be sent in
UPPERCASE, e.g. sbin0001234 fails validation
even though SBIN0001234 passes
|
bank_name |
string | Required | Bank name (max 100 chars) |
amount |
number | Required | Transfer amount in ₹ (min ₹10 — max ₹2,00,000) |
partner_request_id |
string | Optional | Your unique order ID. Strongly recommended for duplicate prevention — without it, a retried request can trigger a second real transfer. |
Response — Success
{
"success": true,
"message": "Payout transferred successfully.",
"data": {
"transaction": {
"transaction_id": "KRESTAA9MKMEQ5LMV29C2",
"internal_txn_id": "8e6bf7e9-8e19-47f3-affc-88d02f69b86a",
"custom_txn_id": "KRESTAA9MKMEQ5LMV29C2",
"reference_id": "RKIT-ORDER-OR-UTR-XXXX",
"partner_request_id": "YOUR-UNIQUE-ORDER-ID",
"status": "success",
"amount": 5000.00,
"charge": 10.00,
"net_amount": 5010.00
}
}
}
Note: the commission field here is called
charge, not commission — a naming quirk specific to
this endpoint. Webhooks (below) use commission instead.
Response — Pending
{
"success": true,
"message": "Payout is being processed. You will be notified once settled.",
"data": {
"transaction": {
"transaction_id": "KRESTAA9MKMEQ5LMV29C2",
"internal_txn_id": "8e6bf7e9-8e19-47f3-affc-88d02f69b86a",
"custom_txn_id": "KRESTAA9MKMEQ5LMV29C2",
"reference_id": null,
"partner_request_id": "YOUR-UNIQUE-ORDER-ID",
"status": "pending",
"amount": 5000.00,
"charge": 10.00,
"net_amount": 5010.00
}
}
}
transaction block at all — just an error message. The refund
still happens automatically; check the webhook or the status endpoint below to see it
reflected.
| Status | Meaning |
|---|---|
success |
Money credited to beneficiary |
pending |
Bank processing — callback will follow |
failed |
Transfer failed — amount already refunded to wallet |
refunded |
Transfer failed — amount refunded to wallet |
GET
/api/v1/transactions/service/payout ▶
| Param | Description | Default |
|---|---|---|
status |
Filter: success / pending / failed /
refunded
|
All |
from_date |
Start date — YYYY-MM-DD |
— |
to_date |
End date — YYYY-MM-DD |
— |
page |
Page number | 1 |
limit |
Records per page (max 100) | 15 |
Single Transaction
Accepts either your custom_txn_id (KRESTA...) or the
internal UUID — both work. There's no dedicated
/payout/status/{id} route; this shared endpoint is the way to look up a
payout after creation.
DMT — Domestic Money Transfer Requires RD Device
IMPS/NEFT remittance on behalf of a "remitter" (your end-customer) to any bank beneficiary — a strictly sequential, 8-step regulatory flow.
exists/is_kyc_verified from Step 1 first — if
is_kyc_verified: true, skip straight to Step 5 or Step 7. Re-running Step 2 on
an already-registered remitter is now blocked with a clear 422 before it ever
reaches the provider, but avoid the wasted round-trip by checking client-side too.
1 POST
/api/v1/dmt/remitter/profile ▶
Call this first, every time. Tells you if the mobile number is a new or existing remitter, and if existing, returns their beneficiaries + KYC status in one call.
| Field | Type | Required | Description |
|---|---|---|---|
mobile_number |
string | Required | 10-digit remitter mobile number |
Response — New Mobile Number
{
"success": true,
"message": "Remitter Not Found",
"data": {
"exists": false,
"is_verified": false,
"is_kyc_verified": false,
"statuscode": "RNF",
"status": "Remitter Not Found",
"data": {
"referenceKey": "mTN2LUVBFvr034...",
"pidOptionWadh": "E0jzJ/P8UopUHAieZn8CKqS4WPMi5ZSYXgfnlfkWjrc="
}
}
}
Response — Already Exists & KYC-Verified
{
"success": true,
"message": "Success",
"data": {
"exists": true,
"is_verified": true,
"is_kyc_verified": true,
"statuscode": "TXN",
"status": "Success",
"data": {
"mobileNumber": "9998887776",
"firstName": "Ramesh",
"lastName": "Kumar",
"limitAvailable": "24143.00",
"beneficiaries": [
{
"id": "2cbd9684548d5b36335876735d8cee90",
"name": "Suresh Verma",
"account": "50100123456789",
"ifsc": "HDFC0000123",
"bank": "HDFC BANK",
"beneficiary_id": "7c1e2f3a-1234-4abc-9def-1234567890ab"
}
],
"referenceKey": "o6p8y4S/xHSwmh53...",
"pidOptionWadh": "E0jzJ/P8UopUHAieZn8CKqS4WPMi5ZSYXgfnlfkWjrc="
}
}
}
| Field | Description |
|---|---|
exists |
Our own field — whether this mobile number is already a remitter (corrected for a provider quirk, see below) |
is_verified |
Registration (Step 2) + OTP (Step 3) done or not |
is_kyc_verified |
Biometric eKYC (Step 4) done or not — Step 5 depends on this being
true
|
data.beneficiaries[].beneficiary_id |
Our internal UUID, injected when a match is found — use this (not the
provider's own id) in every other DMT call |
data.pidOptionWadh |
Needed for Step 4's biometric capture — fresh every ~15 min, always re-fetch right before capturing, never cache |
exists/is_verified/is_kyc_verified
are always accurate, even mid-onboarding. Decide your next step from these three
flags: is_verified: false -> Step 2. is_verified: true,
is_kyc_verified: false -> Step 4. is_kyc_verified: true -> Step
5 or 7.
2 POST
/api/v1/dmt/remitter/register ▶
Only when Step 1 returned exists: false. Links the
remitter's Aadhaar with the provider and sends an OTP.
| Field | Type | Required | Description |
|---|---|---|---|
mobile_number |
string | Required | 10-digit remitter mobile number |
aadhaar_number |
string | Required | 12-digit Aadhaar (AES-encrypted server-side before upstream — never logged/stored plaintext) |
Response — OTP Sent
{
"success": true,
"message": "OTP Successfully sent",
"data": {
"statuscode": "OTP",
"status": "OTP Successfully sent",
"data": { "validity": "2026-08-04 00:24:27", "referenceKey": "W69kpF1qSYuLOenm..." }
}
}
OTP goes to the remitter's mobile — use it in Step 3 immediately, validity is ~15 min.
Response — Already Registered (called by mistake)
{ "success": false, "message": "This customer is already registered — there is no need to register them again. You can continue directly to adding their beneficiary and sending money.", "data": null }
HTTP 422 — blocked before reaching the provider.
3 POST
/api/v1/dmt/remitter/verify-otp ▶
| Field | Type | Required | Description |
|---|---|---|---|
mobile_number |
string | Required | Same as Step 2 |
otp |
string | Required | 6-digit OTP received by the remitter |
Response — Success
{
"success": true,
"message": "Mobile validated successfully please proceed for kyc",
"data": { "success": true, "statuscode": "KYC", "data": { "referenceKey": "74kzm0Db..." } }
}
referenceKey means too much time passed
between Step 2 and 3 — re-run Step 2 fresh and complete Step 3 immediately with the
new OTP. This failed attempt is still chargeable.4 POST
/api/v1/dmt/remitter/kyc ▶
Requires a real biometric capture from a UIDAI-certified RD Service device (e.g. Mantra MFS110) or AadhaarFaceRD — this data cannot be fabricated, it must come from actual certified capture hardware/software on your end.
| Field | Type | Required | Description |
|---|---|---|---|
mobile_number |
string | Required | Verified remitter's mobile number |
capture_type |
string | Optional | FINGER (default) or FACE |
biometric_data |
object | Required | Raw RD Service PID block, unmodified — see below |
latitude/longitude |
number | Optional | Capture location |
biometric_data shape
{
"ci": "20280813", "hmac": "<from RD Service>", "pidData": "<base64>",
"ts": "2026-08-04T11:09:59", "dc": "00c7f4af-...", "mi": "MFS110",
"dpId": "MANTRA.MSIPL", "mc": "<from RD Service>",
"rdsId": "RENESAS.MANTRA.001", "rdsVer": "1.5.1",
"Skey": "<from RD Service>", "srno": "9604562"
}
pidData/hmac/Skey,
errCode: "0") before submitting.
Success unlocks Step 5. This is only needed for brand-new remitters —
check is_kyc_verified from Step 1 first.
GET /api/v1/dmt/banks/search?query=...
▶
Needed before Step 5 — resolves a numeric bank_id from an IFSC code or
bank name.
| Param | Required | Description |
|---|---|---|
query |
Required | Full/partial IFSC (e.g. HDFC0000123) or a bank-name substring
(e.g. state bank) |
force_refresh |
Optional | true to bypass the 24h cache |
{
"success": true, "message": "OK",
"data": {
"query": "HDFC0000123",
"matches": [ { "bank_id": 11263, "name": "HDFC BANK", "ifsc_alias": "HDFC", "imps": true, "neft": true } ],
"total_banks": 1847
}
}
Cached 24h per provider — repeated calls don't cost anything extra.
5 POST
/api/v1/dmt/beneficiary/add ▶
Only after is_kyc_verified: true. Registers who the money is going to.
| Field | Type | Required | Description |
|---|---|---|---|
remitter_mobile_number |
string | Required | Verified & KYC-completed remitter |
beneficiary_mobile_number |
string | Required | Beneficiary's mobile number |
account_number |
string | Required | 8–20 characters |
ifsc |
string | Required | Format AAAA0###### |
bank_id |
integer | Required | From Bank ID Lookup above |
name |
string | Required | Beneficiary account holder name, max 100 chars |
Response — OTP Sent
{
"success": true, "message": "OTP Successfully sent",
"data": {
"beneficiary_id": "7c1e2f3a-1234-4abc-9def-1234567890ab",
"statuscode": "OTP", "status": "OTP Successfully sent",
"data": { "beneficiaryId": "2cbd9684...", "referenceKey": "gJFqtmhE..." }
}
}
beneficiary_id is our internal UUID — use
it in every subsequent step (6/7/8/Delete). data.beneficiaryId is the
provider's own ID — never pass that one back to us.6 POST
/api/v1/dmt/beneficiary/verify-otp ▶
The OTP from Step 5 goes to the remitter's mobile (not the beneficiary's) — the remitter is confirming they want to add this beneficiary.
| Field | Type | Required | Description |
|---|---|---|---|
beneficiary_id |
string (uuid) | Required | From Step 5's response |
otp |
string | Required | 6-digit OTP received by the remitter |
{ "success": true, "message": "Success", "data": { "success": true, "beneficiary_id": "7c1e2f3a-...", "statuscode": "TXN" } }
Delete Beneficiary (2-step: Initiate -> Confirm — not part of the sequential flow) ▶
Any already-verified beneficiary can be deleted at any time.
Sends an OTP to the remitter's mobile — does not delete by itself.
Body: beneficiary_id (must have no pending transfer, or you
get 409).
Body: beneficiary_id, otp. On success the beneficiary is
permanently removed and its ID becomes invalid everywhere else.
success fields — outer
(request processed) and data.success (deletion actually happened, e.g.
false on a wrong OTP). Check both.
7 POST
/api/v1/dmt/transfer/generate-otp ▶
Required immediately before every transfer — Step 6's OTP cannot be reused.
| Field | Type | Required | Description |
|---|---|---|---|
beneficiary_id |
string (uuid) | Required | Must already be OTP-verified (Step 6) |
amount |
number | Required | Intended transfer amount (min 1) |
{ "success": true, "message": "OTP Successfully sent", "data": { "beneficiary_id": "7c1e2f3a-...", "statuscode": "OTP", "data": { "referenceKey": "NjINNkM5...", "isTxnBioAuthRequired": false } } }
8 POST
/api/v1/dmt/transfer ▶
| Field | Type | Required | Description |
|---|---|---|---|
beneficiary_id |
string (uuid) | Required | Same as Step 7 |
transfer_mode |
string | Required | IMPS or NEFT |
amount |
number | Required | Must match Step 7 — min ₹1, max ₹2,00,000 (also bound by your package's slab range) |
otp |
string | Required | 6-digit OTP from Step 7 |
partner_request_id |
string | Optional | Your idempotency key (max 100 chars) — safely retry a timed-out call with the
same value and get 409 instead of a double-send
|
Response — Success
{
"success": true, "message": "DMT transfer completed successfully.",
"data": {
"transaction": {
"transaction_id": "TXN20260802ABCDE", "internal_txn_id": "b3f1c2d4-...",
"reference_id": "IP20260802123456", "utr": "IP20260802123456",
"partner_request_id": "RESELLER-ORDER-9981", "status": "success",
"amount": 5000, "commission": 12.5, "net_amount": 5012.5
},
"statuscode": "TXN", "status": "Success", "data": { "txnReferenceId": "IP20260802123456" }
}
}
Response — Pending (timeout, no definitive answer yet)
{
"success": true, "message": "DMT transfer initiated. Check status shortly.",
"data": {
"transaction": { "transaction_id": "TXN20260802ABCDE", "status": "pending", "amount": 5000, "commission": 12.5, "net_amount": 5012.5 },
"error": "cURL error 28: Operation timed out"
}
}
pending until a definitive failure is
confirmed (via Status Check) — this avoids a "double-loss"
where money actually transferred but got refunded anyway. Follow the same rule in
your own integration.Response — Failed & Refunded
{ "success": false, "message": "DMT transfer failed. Amount has been refunded to your wallet.", "data": { "transaction": { "status": "refunded", "amount": 5000, "commission": 12.5, "net_amount": 5012.5 } } }
GET
/api/v1/dmt/status/{transactionId} ▶
{transactionId} is the transaction_id from Step 8. Resolved
transactions return instantly from our own records; only still-pending
ones trigger a fresh provider check internally.
{
"success": true, "message": "DMT status retrieved.",
"data": {
"transaction_id": "TXN20260802ABCDE", "utr": "IP20260802123456",
"partner_request_id": "RESELLER-ORDER-9981", "amount": 5000,
"commission": 12.5, "net_amount": 5012.5, "status": "success",
"created_at": "2026-08-02T16:33:59.000000Z"
}
}
Common Pitfalls
| Pitfall | Fix |
|---|---|
| Re-running Register/Verify/eKYC on an existing remitter | Check is_kyc_verified from Step 1 first — skip straight to Step 5/7 if
true
|
OTP session expired ("Invalid referenceKey") |
Sessions last ~15 min — complete each OTP chain immediately, don't delay between steps |
Reusing a cached pidOptionWadh |
Always re-fetch via Step 1 right before a biometric capture — it expires in ~15 min too |
| Treating a transfer timeout as "failed" | Treat as pending, confirm via Status Check before assuming failure |
Retrying /transfer after a timeout without partner_request_id
|
Always send one — lets a retry return 409 instead of risking a second
real transfer |
Error Reference
| Code | Message | Reason |
|---|---|---|
| 404 | Beneficiary not found on your account | Wrong/deleted beneficiary_id |
| 409 | A transfer to this beneficiary is already pending | Check status before retrying |
| 409 | Duplicate request — partner_request_id already exists |
Safe-retry protection — check status of the original instead |
| 409 | Cannot delete a beneficiary with a transfer still in progress | Resolve the pending transfer first |
| 422 | Amount ₹X is outside the allowed range | Outside your package's slab range for DMT |
Merchant Onboarding Live
The prerequisite for AEPS — every retailer/outlet must be onboarded here before any AEPS transaction will work for them.
Multi-Retailer Architecture
This is the most important concept to understand before integrating AEPS. Your platform uses
one API key — but each retailer onboards with their own
Aadhaar/PAN and gets a unique outlet_id. You save that outlet_id
against your retailer's record, and pass it explicitly on every AEPS call for that retailer.
outlet_id only works with the API
key that onboarded it — a different reseller's key gets
404 Outlet not found on your account. Within your own key, though, sending the
right outlet_id for the right retailer is your responsibility —
we can't tell which of your retailers a call is "really" for.
1 POST
/api/v1/merchant-onboarding/signup ▶
Onboard a new retailer, once. Needs the retailer's own identity (Aadhaar/PAN/name/DOB) — the outlet is registered under their own identity.
| Field | Required | Description |
|---|---|---|
mobile |
Yes | 10-digit, Aadhaar-registered mobile |
name |
Yes | Must exactly match the PAN name |
gender |
Yes | M, F, or T |
pan |
Yes | 10-character individual PAN |
email |
Yes | Valid email |
address_full, address_city,
address_pincode
|
Yes | Must match the Aadhaar-registered address |
aadhaar_number |
Yes | 12-digit — encrypted server-side, send plaintext to us |
date_of_birth |
Yes | YYYY-MM-DD |
latitude/longitude |
Yes | Real outlet/shop location — used for geo-fencing on every future transaction |
Response
{
"success": true, "message": "Transaction Successful",
"data": {
"success": true, "outlet_id": "714724",
"raw": { "data": { "outletId": 714724, "name": "A**HIT G**TA", "state": "RAJASTHAN" } }
}
}
data.outlet_id immediately against
the retailer's record. This response is the only place you get it — signing up again
doesn't mint a new one, it just updates the existing outlet per the provider's docs.
2 POST
/api/v1/merchant-onboarding/biometric-kyc-status ▶
Call right after Signup to check whether biometric submission is needed.
| Field | Required | Description |
|---|---|---|
outlet_id |
Yes | From Signup |
sp_key |
Yes | Always send "WAP" for AEPS |
{
"success": true, "data": {
"action_required": true, "status": "PENDING",
"outlet_aadhaar": null,
"reference_key": "GioMS1k4VyBbY/r3LUzyR9sSaXtEIb6R3nAjtq9gL9AvqMO/4A0wgRZAarU7y7Sn"
}
}
| Field | Description |
|---|---|
action_required |
true -> Biometric KYC (Step 3) is required next |
outlet_aadhaar |
Non-null -> provider already has an Aadhaar on file, skip sending it in Step 3 |
reference_key |
Required by Step 3 — single-use |
3 POST
/api/v1/merchant-onboarding/biometric-kyc ▶
Only when Step 2 returned action_required: true. Requires the
retailer's own fingerprint capture.
| Field | Required | Description |
|---|---|---|
outlet_id |
Yes | — |
reference_key |
Yes | From Step 2 |
aadhaar_number |
No | Only if Step 2's outlet_aadhaar was empty |
capture_type |
No | FINGER or FACE, default FINGER |
biometric_data |
Yes | RD Service capture output — same shape as DMT's, see above |
{ "success": true, "data": { "success": true, "message": "Biometric KYC completed" } }
4 POST
/api/v1/merchant-onboarding/aeps-readiness ▶
Call after Step 3, then periodically until aeps_ready: true. This is the
real signal that the outlet can transact — bank approval takes time.
| Field | Required |
|---|---|
outlet_id |
Yes |
{
"success": true, "data": {
"aeps_ready": true, "wap_status": true,
"biometric": { "status": "Biometric authentication already completed", "action_required": false },
"record": { "outletId": 714724, "products": { "wapStatus": true } }
}
}
aeps_ready: false -> any AEPS call returns 403. Poll this
until true. aeps_ready/wap_status reflect
bank approval; biometric.* reflects
biometric-KYC completion — two separate checks bundled into one
call.
AEPS Requires RD Device
Aadhaar Enabled Payment System — cash withdrawal, balance enquiry, and mini-statement at an onboarded outlet. Requires Merchant Onboarding to be complete for that outlet first.
Key Rules
| Rule | Why |
|---|---|
One outlet_id = one retailer = one physical shop, always |
UIDAI compliance — daily biometric 2FA, geo-fencing, and bank approval are all per-outlet |
| Geo-fencing: transaction location must be within 3km of the outlet's registered location | Provider's AEPS compliance requirement |
| 2 failed Cash Withdrawals on the same customer Aadhaar -> auto-blocked | Fraud prevention — customer-level, not retailer-level. Needs admin EDD to unblock |
| Outlet Login (daily 2FA) uses the retailer's Aadhaar/finger — everything else uses the customer's | Two different people, don't mix them up |
POST /api/v1/aeps/banks
▶
Call before any transaction to find the customer's bank — gives you the
bankiin every other AEPS call needs.
| Field | Required | Description |
|---|---|---|
outlet_id |
Yes | — |
force_refresh |
No | Default cached response (24h) |
data envelope.{
"statuscode": "TXN", "status": "Banks fetched successfully",
"data": [
{ "bankId": 109005, "name": "STATE BANK OF INDIA", "iin": "607094", "aepsEnabled": true },
{ "bankId": 1, "name": "AIRTEL PAYMENTS BANK", "iin": "990320", "aepsEnabled": true }
]
}
iin is what you pass as bankiin in every other AEPS call.
POST /api/v1/aeps/outlet-login-status
▶
Check daily, before any transaction — has today's 2FA already been done?
| Field | Required |
|---|---|
outlet_id |
Yes |
type |
No — leave blank except for "CashDeposit"
|
{ "success": true, "data": { "logged_in": false, "actcode": "LOGINREQUIRED" } }
logged_in: false -> run Outlet Login first.
POST /api/v1/aeps/outlet-login
▶
Once per calendar day (expires at midnight), before any transaction. Uses the retailer's own Aadhaar and fingerprint — not the customer's.
| Field | Required | Description |
|---|---|---|
outlet_id |
Yes | — |
aadhaar_number |
Yes | Retailer/outlet-owner's own Aadhaar |
biometric_data |
Yes | Retailer's own fingerprint capture |
latitude/longitude |
Yes | Transaction-time location — checked for geo-fencing |
{ "success": true, "data": { "success": true, "message": "SUCCESS" } }
"Biometrics locked by Aadhaar holder", the retailer has locked their
own biometric via the UIDAI app/portal — unlocking is their responsibility.POST /api/v1/aeps/transaction-otp
▶
Only needed when the Cash Withdrawal amount will be above ₹5,000.
| Field | Required |
|---|---|
outlet_id, bankiin |
Yes |
aadhaar_number, mobile (customer's) |
Yes |
amount, latitude/longitude |
Yes |
{ "success": true, "data": { "message": "OTP Successfully sent", "reference_key": "F7Z7WzbRJZeF5nM1...", "otp_required": true } }
otp_required: true (off-us) -> embed the customer's OTP in the withdrawal
capture. false (on-us) -> no OTP needed, but
reference_key is still required in the withdrawal call.
POST /api/v1/aeps/cash-withdrawal
▶
| Field | Required | Description |
|---|---|---|
outlet_id, bankiin |
Yes | — |
mobile, aadhaar_number |
Yes | Customer's |
amount |
Yes | Max ₹50,000 |
biometric_data |
Yes | Customer's fingerprint capture |
reference_key |
Conditional | Required above ₹5,000 — from Transaction OTP |
latitude/longitude |
Yes | Checked for geo-fencing — missing/wrong coordinates directly cause rejection, there's no fallback to the outlet's registered location |
Response — Success
{
"success": true, "data": {
"success": true, "message": "Transaction Successful",
"transaction": { "transaction_id": "KRESTAA9MKMEQ5LMV29C2", "status": "success", "amount": 500, "commission": 5.0 },
"provider": { "bankName": "State Bank of India", "bankAccountBalance": "1520.00" }
}
}
success: true only means "request processed" —
check data.success/data.transaction.status for the real
outcome. A rejected withdrawal is still HTTP 200.Common bank/UIDAI-side rejection messages: "No account found for given Aadhaar
number", "Pre auth failed for given request",
"U16Risk threshold has exceeded", "UIDAI_ERROR552 Invalid wadh
element".
POST /api/v1/aeps/balance-enquiry
▶
Same body as Cash Withdrawal, minus amount/reference_key.
{ "success": true, "data": { "success": true, "balance": "1520.00" } }
POST /api/v1/aeps/mini-statement
▶
Same body as Balance Enquiry.
{
"success": true, "data": {
"success": true,
"mini_statement": [
{ "date": "02/08/2026", "txnType": "CR", "amount": "1.00", "narration": "01015522" },
{ "date": "18/07/2026", "txnType": "DR", "amount": "2000.00", "narration": "NFI/CASH" }
]
}
}
Error Reference
| Code | Message | Reason |
|---|---|---|
| 403 | This outlet is not yet approved for AEPS by the bank | Check AEPS Readiness before retrying |
| 403 | This Aadhaar is blocked after repeated biometric mismatches | Needs EDD (physical re-verification) via admin |
| 404 | Outlet not found on your account | Missing/wrong outlet_id, or it belongs to a different reseller |
| 422 | Transaction location is Xkm from the registered outlet | Geo-fence violation — max 3km |
| 422 | reference_key is required for withdrawals above ₹5,000 |
Run Transaction OTP first |
Wallet
Check your wallet balance and transaction history.
GET /api/v1/wallet/balance
▶
{
"success": true,
"data": {
"wallet_id": "9c1b4f5a-...",
"total_balance": "46000.0000",
"available_balance": "45000.0000",
"hold_balance": "0.0000",
"unsettled_balance": "1000.0000"
}
}
available_balance— your spendable balance. Payouts/DMT debit from here, and refunds land back here.unsettled_balance— money your customers have paid you via PayIn. It sits here until klyropay runs settlement (a manual/batch step on our side, not instant) and moves it intoavailable_balance.
| Field | Description |
|---|---|
available_balance |
Spendable now — usable for Payout/DMT |
unsettled_balance |
PayIn collections not yet settled to available_balance |
hold_balance |
Amount currently locked/on-hold — not available for use |
total_balance |
Sum of all three above — informational only, not a single spendable number |
GET /api/v1/wallet/transactions
▶
Full ledger of every debit/credit — useful for reconciling refunds and settlements against your own records.
Callback (Webhook)
klyropay sends POST requests to your registered callback URL when a transaction status changes — currently wired for PayIn and Payout. (DMT and AEPS resolve synchronously in the API response, or via status-polling — see their sections above.)
If the provider settles asynchronously, you'll get 2 callbacks — first
pending (immediately, utr not yet known), then the final
success/failed/refunded once the bank confirms. If the
provider resolves the transfer instantly (accepted or rejected on the spot), you'll get just
1 callback already carrying the final status.PayIn: Single callback on final status (
success,
failed, expired)Retry: up to 5 attempts, 5 minutes apart — but only if delivery itself fails (timeout, connection error). If your server responds with a non-200 HTTP status (4xx/5xx) but the request otherwise completes, it is not automatically retried — it's logged as undelivered. Always poll the relevant status endpoint as a fallback rather than relying solely on retries.
Callback Request Headers
POST https://yourserver.com/your-callback-endpoint Content-Type: application/json X-Krestapay-Event: transaction.updated X-Krestapay-Signature: hmac_sha256_signature
Note: the header prefix is X-Krestapay-* (klyropay's
underlying platform name) — not X-KlyroPay-*. Match on the exact header name
shown above.
PayIn Callback Payload
{
"event": "transaction.updated",
"transaction_id": "KRESTAA9MKMEQ5LMV29C2",
"partner_request_id": null,
"service": "payin",
"status": "success",
"amount": 300.00,
"commission": 3.00,
"net_amount": 297.00,
"txn_ref": "ORD1778403905ZNCI",
"utr": "60750480359",
"order_id": "ORD-260510143504-ZNCI",
"timestamp": "2026-05-10T14:35:16.000000Z"
}
partner_request_id is always
null for PayIn — use order_id (your own reference) or
transaction_id to correlate instead.
Payout Callback #1 — Pending
{
"event": "transaction.updated",
"transaction_id": "KRESTA0D7PUDJTSHLYX",
"partner_request_id": "APPV26051011205050185762",
"service": "payout",
"status": "pending",
"amount": 1080.00,
"commission": 21.60,
"net_amount": 1101.60,
"utr": null,
"timestamp": "2026-05-10T05:50:53.398478Z"
}
Payout Callback #2 — Success
{
"event": "transaction.updated",
"transaction_id": "KRESTA0D7PUDJTSHLYX",
"partner_request_id": "APPV26051011205050185762",
"service": "payout",
"status": "success",
"amount": 1080.00,
"commission": 21.60,
"net_amount": 1101.60,
"utr": "613011436999",
"timestamp": "2026-05-10T05:52:03.203539Z"
}
Payout Callback #2 — Failed
{
"event": "transaction.updated",
"transaction_id": "KRESTA0D7PUDJTSHLYX",
"partner_request_id": "APPV26051011205050185762",
"service": "payout",
"status": "refunded",
"amount": 1080.00,
"commission": 21.60,
"net_amount": 1101.60,
"utr": null,
"timestamp": "2026-05-10T05:51:15.123456Z"
}
Callback Flow
| Field | Description |
|---|---|
transaction_id |
Your custom_txn_id — never the internal UUID |
partner_request_id |
Your order ID for Payout (always null for PayIn — use
order_id instead)
|
status |
pending -> success / failed /
refunded
|
utr |
Bank UTR number — null until settlement completes, populated in the
final callback |
timestamp |
Callback sent time (ISO 8601) |
Verify Signature
Always verify the signature to ensure the callback is genuinely from klyropay.
X-Api-Key. It's a separate
secret issued once when your API key was created (shown to you a single time — store it
securely, it can't be re-displayed later). Using your X-Api-Key value here will
make every signature check fail.
$sig = $_SERVER['HTTP_X_KRESTAPAY_SIGNATURE'];
$rawBody = file_get_contents('php://input');
$payload = json_decode($rawBody, true);
$expected = hash_hmac(
'sha256',
$rawBody, // hash the raw bytes, not a re-encoded copy
'YOUR_SIGNING_SECRET' // issued at key-creation time — not your API key
);
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
// Valid — process the callback
http_response_code(200);
echo json_encode(['status' => 'ok']);
const crypto = require('crypto');
const sig = req.headers[
'x-krestapay-signature'
];
// Hash the raw request body bytes, not
// JSON.stringify(req.body) — re-serializing
// can change key order/spacing and break
// the comparison.
const expected = crypto
.createHmac('sha256', 'YOUR_SIGNING_SECRET')
.update(req.rawBody)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(sig)
)) {
return res.status(401).send('Unauthorized');
}
res.status(200).json({ status: 'ok' });
Error Reference
All error responses follow this format:
{ "success": false, "message": "Error description.", "data": null }
Common — All Services
| HTTP Code | Message | Reason |
|---|---|---|
| 401 | API key is missing | X-Api-Key header not sent |
| 401 | Invalid API key | Key is incorrect |
| 401 | API key is inactive or expired | Key revoked or expired |
| 403 | No IP whitelist configured | No IP approved yet |
| 403 | Your IP is not whitelisted | Request from non-whitelisted IP |
| 403 | You do not have access to this service | Service not assigned to your account specifically |
| 403 | No package assigned to your account | Ask support to assign a commission package |
| 403 | Your package does not include this service | Your package has no commission slab for this service |
| 409 | Duplicate request | Same idempotency key (partner_request_id/order_id) already
in use |
| 422 | Validation failed | Invalid request fields |
| 422 | Insufficient wallet balance | Not enough available_balance |
| 422 | Minimum/Maximum amount is ₹X | Amount outside the provider's allowed range |
| 422 | Amount ₹X is outside the allowed range | Amount outside your package's commission slab range (checked separately from the above) |
| 429 | Rate limit exceeded | Too many requests per minute |
| 503 | Service is currently inactive | Temporarily disabled — contact support |
Service-specific errors (DMT, AEPS) are listed at the bottom of their own sections: DMT Errors · AEPS Errors.
Quick Reference — All Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/payin/create |
Create PayIn request |
| GET | /api/v1/payin/status/{txn_ref} |
Check PayIn status |
| GET | /api/v1/payin/transactions |
PayIn history |
| POST | /api/v1/payout/process |
Initiate payout |
| GET | /api/v1/transactions/service/payout |
Payout history |
| GET | /api/v1/transactions/service/payout/{id} |
Single payout detail |
| POST | /api/v1/dmt/remitter/profile |
DMT Step 1 — remitter profile check |
| POST | /api/v1/dmt/remitter/register |
DMT Step 2 — register new remitter |
| POST | /api/v1/dmt/remitter/verify-otp |
DMT Step 3 — verify remitter OTP |
| POST | /api/v1/dmt/remitter/kyc |
DMT Step 4 — remitter eKYC |
| GET | /api/v1/dmt/banks/search |
DMT — bank ID lookup |
| POST | /api/v1/dmt/beneficiary/add |
DMT Step 5 — add beneficiary |
| POST | /api/v1/dmt/beneficiary/verify-otp |
DMT Step 6 — verify beneficiary OTP |
| POST | /api/v1/dmt/beneficiary/delete |
DMT — delete beneficiary (initiate) |
| POST | /api/v1/dmt/beneficiary/delete-verify |
DMT — delete beneficiary (confirm) |
| POST | /api/v1/dmt/transfer/generate-otp |
DMT Step 7 — generate transaction OTP |
| POST | /api/v1/dmt/transfer |
DMT Step 8 — transfer |
| GET | /api/v1/dmt/status/{transactionId} |
DMT — status check |
| POST | /api/v1/merchant-onboarding/signup |
Onboard a new retailer/outlet |
| POST | /api/v1/merchant-onboarding/biometric-kyc-status |
Check if biometric KYC is needed |
| POST | /api/v1/merchant-onboarding/biometric-kyc |
Submit biometric KYC |
| POST | /api/v1/merchant-onboarding/aeps-readiness |
Check bank-approval / AEPS readiness |
| POST | /api/v1/aeps/banks |
Bank list (for bankiin) |
| POST | /api/v1/aeps/outlet-login-status |
Check daily 2FA status |
| POST | /api/v1/aeps/outlet-login |
Daily outlet 2FA |
| POST | /api/v1/aeps/transaction-otp |
OTP for withdrawals above ₹5,000 |
| POST | /api/v1/aeps/cash-withdrawal |
Cash withdrawal |
| POST | /api/v1/aeps/balance-enquiry |
Balance enquiry |
| POST | /api/v1/aeps/mini-statement |
Mini statement |
| GET | /api/v1/wallet/balance |
Wallet balance (available_balance + unsettled_balance) |
| GET | /api/v1/wallet/transactions |
Full wallet ledger |