iMoney PA-O Integration
Collect payments from your customers via UPI, QR, Intent, and Net Banking. The Payin APIs (also referred to as Collection APIs) handle the money-in side of your iMoney integration — encrypted by design, webhook-driven, with idempotent retries.
What is iMoney PA-O?
iMoney's Payment Aggregator — Online (PA-O) lets merchants collect digital payments through a single API. This guide covers the Payin service — internally also referred to as the Collection APIs — which handles the money-in flow. The integration follows a two-step Init → Execute flow, after which iMoney notifies your server of the final outcome via webhook.
Naming: "Payin APIs" and "Collection APIs" refer to the same set of endpoints. You'll see both terms used in our dashboard, contracts and onboarding documents — they are interchangeable.
🔐 Encrypted by default
Every request body uses AES-256-GCM. Plaintext is rejected. Your hash_key never leaves your server.
📡 Webhook source-of-truth
The webhook is the authoritative final state. Status check is a fallback only.
🧾 HTTP 200, always
Every API returns HTTP 200. Business outcomes live in response_code inside the JSON body.
⚖️ Built-in dispute flow
Chargebacks have a dedicated webhook track. Status APIs are blocked while a dispute is active.
Environments & Service URLs
Each service has its own dedicated subdomain on both UAT (sandbox) and Production (live). Use the relevant base URL for the API you're calling.
| Service | UAT (Sandbox) | Production (Live) |
|---|---|---|
| Merchant Dashboard | uatdashboard.imoneypg.com | dashboard.imoneypg.com |
| Payin Open API init & execute | uatpayin.imoneypg.com | payin.imoneypg.com |
| Payin Webhook callbacks originate from here | uatpayinhooks.imoneypg.com | payinhooks.imoneypg.com |
| Payin Status Check | uatpayinstatus.imoneypg.com | payinstatus.imoneypg.com |
Note: Status check calls use a different base URL than init/execute. The Open API (init/execute) lives at payin.imoneypg.com, while the status check API lives at payinstatus.imoneypg.com. Webhooks reach you from payinhooks.imoneypg.com (still always sourced from one of the two trusted IPs).
Three rules that govern everything
- Every API response returns HTTP 200. Never use HTTP status code to determine transaction outcome. Always read
response_codeinside the JSON body. - Every request body must be AES-256-GCM encrypted. Plaintext requests are rejected.
- Webhook is the source of truth. The status check API is a fallback only.
Changelog #
A short release history for this guide. For breaking changes, a migration note is sent separately to tech@ contacts.
NEW endpoint: Resend Callback — POST /api/v3/payin/resend-callback/{pg_ref}/resend/. Manually re-trigger webhook delivery for a transaction whose webhook was missed. Rate-capped at 10 resend attempts per transaction. Blocked during disputes. Returns response_code: 000 with "queued for delivery" — the webhook arrives asynchronously at your registered URL.
Status check polling changed: previously "do not call before 5 minutes" — now available immediately after execute. You may poll every 5–10 seconds for up to 5 minutes while the customer is on the payment screen. The lifecycle diagram is updated to show ~5 sec rather than 5 min.
Webhook payload field renamed: payin_type → payin_method on the payin.status_update event. Update your handlers to look for data.payin_method rather than data.payin_type.
Decision Guide restructured — new "one rule" framing, a flowchart for the single most critical execute decision, and explicit guidance on when retrying Init is safe. The Master Decision Table now covers every signal source (Execute, Webhook, Status API, Resend, Support confirmation, no-webhook timeouts).
Per-section MD download — every section heading now has a small ⬇ MD button that downloads just that section's markdown (not the whole guide). Handy for sharing a specific topic with your team or pulling a section into an internal wiki.
Major content update. New response codes: 100 (insufficient wallet balance), 115 (unrecognised payin_type), 116 (payin_type not enabled on selected route). HTTP exceptions documented: 103 uses HTTP 429; 101 uses HTTP 503 when the security key is briefly unavailable at gateway boot.
X-Signature is now required on init, status, and balance (previously: init only). Added the X-Merchant-Ref per-endpoint validation rules table. New routing_source codes (MERCHANT/PLATFORM) called out alongside Own-Route / Platform-Route.
Comprehensive Error Response tables added for every endpoint (Init Own-Route, Init Platform-Route, Execute, Status, Balance) — every documented scenario, code, and example message string. Includes the critical transaction_id consumption rule and the Payment Gateway ref:XXXXXXXX handling (do NOT retry; keep Pending; share ref with ops).
Field constraints clarified: all string fields capped at 256 chars; non-string types rejected as 104; customer_vpa is conditional on COLLECT payin_type; currency must be exact case.
Decision Guide updated to cover HTTP 429, HTTP 503, and the 101+ref: execute case — all map to "Keep Pending" with specific recovery steps.
Added a new Edge / CloudFront Error Codes reference — documents the 4xx (400, 403, 404, 405, 414, 416) and 5xx (500, 501, 502, 503, 504) HTTP statuses returned by the CloudFront edge before requests reach iMoney's backend. Critical rule: never mark a transaction as Success or Failed when you receive one of these statuses — always verify with iMoney Operations first.
Removed the duplicate 403 row from Response Code Reference — geographic blocks are now exclusively covered by the new Edge / CloudFront table.
Added a mandatory UAT Sign-off notice — every merchant must complete the UAT Sign-off Form with I Money Pay Private Limited before Production go-live. Without a completed sign-off, I Money Pay Private Limited and its Tech Team will not be held responsible for any losses or integration defects on the merchant side. Request the form from onboarding@imoneypay.in or techpg@imoneypg.com.
Added a Merchant Login button in the header — opens the iMoney Merchant Dashboard (dashboard.imoneypg.com/merchant/auth/login) in a new tab so merchants can sign in without losing their place in the docs.
Renamed "Merchant Types" to "Routing Modes" to remove confusion with billing terminology. Prepaid → Own-Route and Non-Prepaid → Platform-Route. New explicit note: routing mode (which init endpoint to call) is independent of your billing type (prepaid wallet vs. postpaid settlement). Error 104 messages updated to use the new wording.
Introduced 🇮🇳 India-Only Traffic Policy — in compliance with the Reserve Bank of India's Data Localization directive, iMoney accepts API traffic only from Indian IP addresses. VPN, proxy and any non-Indian-origin traffic is blocked at the edge with response_code: 403, even if all other authentication checks would otherwise pass. Host your application on a reputable cloud provider (Amazon AWS, Azure, or GCP) using their India region. This policy has been in effect from 10 June 2026 onward — merchants integrating after this date are bound by it.
Documentation patch — no API contract change. Clarified that Payin APIs and Collection APIs refer to the same set of endpoints. Level 1 support email is now techpg@imoneypg.com (was tech@imoneypg.com). Mobile UX improvements (sticky header on iOS/Android, no horizontal scroll on phones, hamburger sidebar).
Added Balance Check endpoint and the payin.test webhook event. X-Signature & X-Merchant-Ref are now required on every endpoint except execute; webhook retries reduced to 5 attempts; response code 109 repurposed for signature failure.
Initial release of the iMoney PA-O Payin Merchant Integration Guide — two-step Init → Execute flow, AES-256-GCM encryption, status check, full webhook & dispute model.
Quick Start #
Five steps to go from zero to a working integration.
1. Get your credentials
Onboard with iMoney to receive your api_key, hash_key, routing mode, and route name (Own-Route only). Whitelist iMoney's two static IPs on your server.
2. Implement encryption
SHA-256 key derivation + AES-256-GCM body encryption. Ready-to-use code in Python, Node.js, PHP and Java below.
3. Call Init → Execute
Step 1: POST /api/v3/payin/init/ (or /bank/init/). Step 2: POST /api/v3/payin/ returns the UPI link/QR.
4. Receive the webhook
Implement a webhook endpoint that decrypts the payload, verifies the source IP, and updates your transaction status. Always return HTTP 200 immediately.
5. Reconcile & go live
Validate Success / Failed / Pending logic against the Decision Guide. Switch BASE_URL to production, ship.
Credentials & Onboarding #
| Item | Description | Provided By |
|---|---|---|
api_key | Your merchant identifier, sent in every request header | iMoney Onboarding |
hash_key | Your encryption secret — used to derive the AES key and signature. Never expose this. | iMoney Onboarding |
| IP Whitelist (yours) | Your server outbound IP(s) registered with iMoney — requests from unregistered IPs are rejected | Merchant provides, iMoney configures |
| Webhook URL | Your HTTPS endpoint to receive payment status updates | Merchant provides, iMoney configures |
| Route Name(s) | Assigned to your account — required only for Own-Route merchants | iMoney Onboarding |
| Routing Mode | Own-Route or Platform-Route — determines which init endpoint to use. Independent of your billing type. | iMoney Onboarding |
| iMoney Sender IPs | Whitelist these two on your firewall: 166.117.126.71, 99.83.230.145 | iMoney (always these two) |
iMoney staff will never ask for your hash_key. Do not share it under any circumstances — store it in a secure secret manager (Vault, AWS Secrets Manager, etc.).
Routing Modes #
Your account is configured by iMoney for one routing mode, which determines which init endpoint you call. Calling the wrong init endpoint returns error 104.
| Routing Mode | Description | Init Endpoint | route_name |
|---|---|---|---|
Own-Routerouting_source = MERCHANT |
You transact on your own acquirer account. You supply a route_name from your assigned MerchantAcquirerAccount, and iMoney routes through that specific acquirer. |
POST /api/v3/payin/init/ |
Required |
Platform-Routerouting_source = PLATFORM |
iMoney automatically selects the best available platform acquirer for the amount, currency, and payment type combination. | POST /api/v3/payin/bank/init/ |
Not accepted |
The execute endpoint is the same for both modes: POST /api/v3/payin/. If you are unsure of your routing mode, check your onboarding email or contact techpg@imoneypg.com.
Your routing mode (which init endpoint you use) is a separate setting from your billing type (prepaid wallet vs. postpaid settlement). Do not infer one from the other — always use the endpoint that matches the routing mode iMoney configured for your account.
Security Model #
Every request passes through five gates in order:
Incoming Request │ ├─ [1] API-KEY header → Identifies your merchant account ├─ [2] IP Whitelist → Request IP must be pre-registered with iMoney ├─ [3] X-Timestamp header → Unix timestamp, must be within ±5 minutes of server time ├─ [4] X-Signature header → HMAC integrity check (required on init endpoints) └─ [5] Encrypted body → AES-256-GCM payload confidentiality
Required headers
| Header | Required On | Format | Example |
|---|---|---|---|
API-KEY | All endpoints | Plain string | 9b80b436a5ff |
X-Timestamp | All endpoints | Unix seconds | 1716374400 |
X-Signature | Init, status, and balance endpoints | Lowercase hex | a3f8c2… |
X-Merchant-Ref | All endpoints | Your order/merchant reference | ORDER-20260526-001 |
X-Customer-IP | Init endpoints only | Customer's real IP | 203.0.113.45 |
Content-Type | POST endpoints | Fixed | application/json |
X-Merchant-Ref validation by endpoint
| Endpoint | Validation |
|---|---|
POST /init/ or /bank/init/ | Must exactly match merchant_ref in the encrypted body |
POST /api/v3/payin/ (execute) | Must match the merchant_ref from the init call for this transaction_id |
GET /status/{pg_ref}/ | Validated against the merchant_ref stored on the transaction record |
GET /status/merchant/{merchant_ref}/ | Must equal the URL path parameter |
GET /balance/ | Must be present — any non-empty value accepted |
Keep your server clock synced via NTP. Timestamp drift >±5 minutes returns response_code: 108.
Trusted iMoney IPs #
iMoney uses two Global Accelerator static IPs for ALL communication with merchant servers — both for callbacks we send to you and outbound API calls our system makes to you. Whitelist exactly these two:
166.117.126.71
iMoney GA endpoint #1 — primary sender for callbacks and API calls.
99.83.230.145
iMoney GA endpoint #2 — secondary sender, used for redundancy and failover.
If you receive a callback or any request claiming to be from iMoney that originates from an IP other than 166.117.126.71 or 99.83.230.145, treat it as suspicious. Discard the request and report it immediately to techpg@imoneypg.com with the source IP, timestamp, and full headers.
India-Only Traffic Policy — RBI Data Localization #
iMoney accepts API traffic only from Indian IP addresses. In compliance with the Reserve Bank of India's Data Localization directive, all payment data must be stored and processed within India.
- Calls to
payin.imoneypg.com,payinstatus.imoneypg.com, and all other iMoney endpoints must originate from servers physically located in India. - VPN, proxy, anonymiser, and any non-Indian origin traffic is blocked at the edge — regardless of valid API keys / signatures.
- Host your application on a reputable cloud provider with an Indian region — for example Amazon AWS, Microsoft Azure, or Google Cloud (GCP) — or any India-hosted data centre of your choice.
- Whitelist only Indian outbound IPs on your end of the integration.
Requests originating from outside India will be silently dropped or returned with response_code: 403 — even if all other authentication checks would otherwise have passed.
Encryption #
All cryptographic operations are derived from your api_key + hash_key pair. Three steps: derive the key, encrypt the body, sign the request.
Key Derivation #
The 32-byte AES key is derived by SHA-256 hashing the concatenation of your api_key and hash_key.
import hashlib
def derive_key(api_key: str, hash_key: str) -> bytes:
return hashlib.sha256(f"{api_key}{hash_key}".encode()).digest()
def derive_key_hex(api_key: str, hash_key: str) -> str:
return hashlib.sha256(f"{api_key}{hash_key}".encode()).hexdigest()const crypto = require('crypto');
function deriveKey(apiKey, hashKey) {
return crypto.createHash('sha256').update(apiKey + hashKey).digest();
}
function deriveKeyHex(apiKey, hashKey) {
return crypto.createHash('sha256').update(apiKey + hashKey).digest('hex');
}function deriveKey(string $apiKey, string $hashKey): string {
return hash('sha256', $apiKey . $hashKey, true);
}
function deriveKeyHex(string $apiKey, string $hashKey): string {
return hash('sha256', $apiKey . $hashKey);
}import java.security.MessageDigest;
public static byte[] deriveKey(String apiKey, String hashKey) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return md.digest((apiKey + hashKey).getBytes("UTF-8"));
}
public static String deriveKeyHex(String apiKey, String hashKey) throws Exception {
byte[] raw = deriveKey(apiKey, hashKey);
StringBuilder sb = new StringBuilder();
for (byte b : raw) sb.append(String.format("%02x", b));
return sb.toString();
}Encrypting the Request Body #
All payloads must be AES-256-GCM encrypted and wrapped in a JSON envelope.
Wire format
nonce_hex : ciphertext_hex : tag_hex
Wrapped in JSON
{ "data": "a3f8c2...<nonce>:<ciphertext>:<tag>...hex" }import os, json
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def encrypt_payload(payload: dict, api_key: str, hash_key: str) -> bytes:
key = derive_key(api_key, hash_key)
nonce = os.urandom(12)
plain = json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()
enc = AESGCM(key).encrypt(nonce, plain, None)
ct, tag = enc[:-16], enc[-16:]
encrypted_str = f"{nonce.hex()}:{ct.hex()}:{tag.hex()}"
return json.dumps({"data": encrypted_str}).encode()function encryptPayload(payload, apiKey, hashKey) {
const key = deriveKey(apiKey, hashKey);
const nonce = crypto.randomBytes(12);
const plain = Buffer.from(JSON.stringify(payload), 'utf8');
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
const ct = Buffer.concat([cipher.update(plain), cipher.final()]);
const tag = cipher.getAuthTag();
const encStr = `${nonce.toString('hex')}:${ct.toString('hex')}:${tag.toString('hex')}`;
return Buffer.from(JSON.stringify({ data: encStr }));
}function encryptPayload(array $payload, string $apiKey, string $hashKey): string {
$key = deriveKey($apiKey, $hashKey);
$nonce = random_bytes(12);
ksort($payload);
$plain = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$ct = openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $nonce, $tag, '', 16);
$encStr = bin2hex($nonce) . ':' . bin2hex($ct) . ':' . bin2hex($tag);
return json_encode(['data' => $encStr]);
}public static String encryptPayload(String json, String apiKey, String hashKey) throws Exception {
byte[] key = deriveKey(apiKey, hashKey);
byte[] nonce = new byte[12];
new SecureRandom().nextBytes(nonce);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce));
byte[] enc = cipher.doFinal(json.getBytes("UTF-8"));
byte[] ct = Arrays.copyOf(enc, enc.length - 16);
byte[] tag = Arrays.copyOfRange(enc, enc.length - 16, enc.length);
return "{\"data\":\"" + bytesToHex(nonce) + ":" + bytesToHex(ct) + ":" + bytesToHex(tag) + "\"}";
}Computing the Signature #
The X-Signature header is required on all init, status, and balance endpoint calls. It proves that the request has not been tampered with in transit. (Execute calls rely on the AES-GCM auth-tag of the encrypted body for integrity and do not require a signature.)
Signature inputs by endpoint
| Endpoint | ref_id | amount | body_bytes |
|---|---|---|---|
POST /api/v3/payin/init/ | merchant_ref | e.g. "1000.00" | encrypted request body |
POST /api/v3/payin/bank/init/ | merchant_ref | e.g. "1000.00" | encrypted request body |
GET /api/v3/payin/status/<pg_ref>/ | pg_ref | "" | b"" (empty) |
GET /api/v3/payin/status/merchant/<merchant_ref>/ | merchant_ref | "" | b"" (empty) |
GET /api/v3/payin/balance/ | "" | "" | b"" (empty) |
For GET endpoints body_bytes is empty, so body_hash = sha256(b"").hexdigest(). Use the formula in the code samples below — only the ref_id, amount, and body_bytes inputs differ per endpoint.
def compute_signature(api_key, hash_key, timestamp, merchant_ref, amount, body_bytes):
key_hex = derive_key_hex(api_key, hash_key)
body_hash = hashlib.sha256(body_bytes).hexdigest()
message = f"{api_key}|{timestamp}|{merchant_ref}|{amount}|{body_hash}"
return hashlib.sha256(f"{message}{key_hex}".encode()).hexdigest()function computeSignature(apiKey, hashKey, timestamp, merchantRef, amount, bodyBuffer) {
const keyHex = deriveKeyHex(apiKey, hashKey);
const bodyHash = crypto.createHash('sha256').update(bodyBuffer).digest('hex');
const message = `${apiKey}|${timestamp}|${merchantRef}|${amount}|${bodyHash}`;
return crypto.createHash('sha256').update(message + keyHex).digest('hex');
}function computeSignature($apiKey, $hashKey, $timestamp, $merchantRef, $amount, $bodyBytes): string {
$keyHex = deriveKeyHex($apiKey, $hashKey);
$bodyHash = hash('sha256', $bodyBytes);
$message = "{$apiKey}|{$timestamp}|{$merchantRef}|{$amount}|{$bodyHash}";
return hash('sha256', $message . $keyHex);
}public static String computeSignature(String apiKey, String hashKey, String timestamp,
String merchantRef, String amount, byte[] bodyBytes) throws Exception {
String keyHex = deriveKeyHex(apiKey, hashKey);
MessageDigest md = MessageDigest.getInstance("SHA-256");
String bodyHash = bytesToHex(md.digest(bodyBytes));
String message = apiKey + "|" + timestamp + "|" + merchantRef + "|" + amount + "|" + bodyHash;
md.reset();
return bytesToHex(md.digest((message + keyHex).getBytes("UTF-8")));
}Full Request Example #
End-to-end payin init in Python — encrypts payload, computes signature, posts to iMoney.
import hashlib, json, os, time, requests
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
API_KEY = "your_api_key"
HASH_KEY = "your_hash_key"
BASE_URL = "https://uatpayin.imoneypg.com"
def payin_init(payload: dict) -> dict:
body = encrypt_payload(payload, API_KEY, HASH_KEY)
timestamp = str(int(time.time()))
signature = compute_signature(API_KEY, HASH_KEY, timestamp,
payload["merchant_ref"], payload["amount"], body)
resp = requests.post(
f"{BASE_URL}/api/v3/payin/init/",
data=body,
headers={
"Content-Type": "application/json",
"API-KEY": API_KEY,
"X-Timestamp": timestamp,
"X-Signature": signature,
"X-Merchant-Ref": payload["merchant_ref"],
"X-Customer-IP": payload.get("customer_ip", ""),
},
timeout=30,
)
return resp.json()Transaction Lifecycle #
Your System iMoney Gateway Customer
│ │ │
│── Step 1: Init ─────────────▶│ │
│◀── transaction_id + pg_ref ──│ │
│ (valid for 5 minutes) │ │
│── Step 2: Execute ──────────▶│ │
│◀── response_code: 001 ───────│ │
│ [show QR / UPI link to customer] │
│ │◀──── customer pays ──────────│
│◀── WEBHOOK ──────────────────│ │
│ [poll status after ~5 sec] │ │
│── Status Check ─────────────▶│ │
│◀── response_code: 000/003 ───│ │
The webhook is the primary signal. Status check is available immediately after execute and may be polled to resolve uncertain states — but the webhook remains the authoritative final signal.
Step 1 — Init (Own-Route Merchants) #
Headers: API-KEY, X-Timestamp, X-Signature, X-Merchant-Ref, X-Customer-IP, Content-Type: application/json
Request Fields
| Field | Type | Required | Validation |
|---|---|---|---|
uid | string | Yes | Your internal customer identifier |
merchant_ref | string | Yes | Unique per merchant, forever |
amount | string | Yes | Positive decimal, max 2 decimal places |
currency | string | Yes | "INR" |
payin_type | string | Yes | UPI, INTENT, QR, NETBANKING |
route_name | string | Yes | Your assigned route name |
customer_name | string | Yes | End customer's full name |
customer_mobile | string | Yes | End customer's mobile number |
customer_email | string | No | End customer's email |
customer_vpa | string | Conditional | Required if payin_type is COLLECT. Optional otherwise — stored on the transaction for fraud screening. |
description | string | No | Order description |
All string fields must be sent as JSON strings. Sending a number where a string is expected returns response_code: 104.
String fields are capped at 256 characters. Longer values return response_code: 104.
Request
curl -X POST https://uatpayin.imoneypg.com/api/v3/payin/init/ \
-H "API-KEY: 9b80b436a5ff" \
-H "X-Timestamp: 1716374400" \
-H "X-Signature: a3f8c2d1e5b7..." \
-H "X-Merchant-Ref: ORDER-20260526-001" \
-H "X-Customer-IP: 203.0.113.45" \
-H "Content-Type: application/json" \
-d '{"data":"<encrypted_string>"}'{
"uid": "CUSTOMER-001",
"merchant_ref": "ORDER-20260526-001",
"amount": "1000.00",
"currency": "INR",
"payin_type": "INTENT",
"route_name": "YOUR_MERCHANT_ROUTE",
"customer_name": "Raj Kumar",
"customer_mobile": "9876543210",
"customer_email": "raj@example.com",
"customer_vpa": "raj@upi",
"description": "Order #ORD-12345"
}Response — Success
{
"response_code": "000",
"message": "OK",
"data": {
"transaction_id": "220260526143022123456",
"pg_ref": "220260526143022ABCD",
"expires_in": 300
}
}transaction_id is valid for 300 seconds. If it expires, call init again.
Error Responses — Init (Own-Route)
| Scenario | response_code | message (example) |
|---|---|---|
| Wrong routing mode — account is Platform-Route | 104 | "This endpoint is for merchants that route on their own acquirer. Platform-routed merchants must use /api/v3/payin/bank/init/." |
Missing X-Merchant-Ref header | 104 | "X-Merchant-Ref header is missing." |
| Timestamp missing or outside ±5 min window | 108 | "X-Timestamp expired or missing. Use Unix timestamp ±5 min." |
| Rate limit exceeded (>10 req/s per IP) | 103 | "Rate limit exceeded — max 10 req/s per IP" · HTTP 429 |
| Security key unavailable (transient) | 101 | "Security key unavailable. Retry in a moment." · HTTP 503 |
| Body decryption failed | 110 | "Failed to decrypt request payload." |
X-Signature verification failed | 109 | "X-Signature verification failed." |
| A field sent as non-string type | 104 | "Invalid type for field \"amount\": must be sent as a JSON string." |
| Field exceeds 256-character limit | 104 | "Field \"description\" exceeds maximum length of 256 characters." |
| Required field missing from body | 104 | "Missing required field(s): customer_mobile, customer_name" |
customer_vpa missing for COLLECT type | 104 | "customer_vpa (UPI VPA) is required for COLLECT payment type." |
| Invalid amount format | 106 | "Invalid amount: ..." |
| Currency not supported or wrong case | 107 | "Currency \"inr\" not supported. Use exact format e.g. INR." |
route_name does not exist for this merchant | 105 | "Route \"ROUTE_X\" not found for this merchant." |
| Route exists but is inactive | 105 | (route inactive message) |
| Route found but credentials not configured | 105 | "Route \"ROUTE_X\" is not fully configured (acquirer credentials missing). Contact support." |
payin_type not recognised (system level) | 115 | "\"NETPAY\" is not a recognised payment type. Valid types: INTENT, NETBANKING, QR, UPI" |
payin_type not enabled on this route | 116 | "\"NETBANKING\" is not enabled on route \"ROUTE_X\". Enabled on this route: INTENT, UPI" |
| Amount below route's minimum limit | 106 | "Amount 50.00 below minimum 100.00." |
| Amount exceeds route's per-transaction limit | 106 | "Amount 500000.00 exceeds per-transaction limit 100000.00." |
| Fraud engine (FUSE) unreachable | 101 | "Fraud screening service temporarily unavailable. Transaction blocked for safety." |
| Fraud engine blocked this transaction | 112 | "Transaction blocked due to fraud risk." |
| Insufficient prepaid wallet balance | 100 | "Insufficient wallet balance. Available: 50.00, required: 62.50 (estimated charges + 50 safety buffer)." |
| Postpaid wallet below safety floor | 100 | "Wallet balance below minimum safety floor. Available: -200.00, minimum required: 0." |
| Wallet not found for currency (prepaid) | 101 | "No active wallet for this currency." |
Duplicate merchant_ref | 102 | "Duplicate merchant_ref — this reference already exists." |
Step 1 — Init (Platform-Route Merchants) #
This endpoint is for merchants configured with routing_source = PLATFORM. Same fields as Own-Route, except route_name is not accepted — iMoney auto-selects the best acquirer for the amount, currency, and payment type.
If your account is configured as Own-Route, calling this endpoint returns 104. Use Init (Own-Route) instead.
Error Responses — Init (Platform-Route)
All errors from Init (Own-Route) apply here with the same codes, except for these differences:
| Scenario | response_code | Notes |
|---|---|---|
| Wrong routing mode — account is Own-Route | 104 | "This endpoint is for platform-routed merchants. Merchants with their own routes must use /api/v3/payin/init/ with route_name." |
| No platform route available for amount/currency/type combination | 105 | "No payin route available [ref:XXXXXXXX]. Contact admin with ref:XXXXXXXX." — include the ref code when contacting support |
(no route_name, 115, 116 errors) | — | Route is selected automatically; these don't apply |
curl -X POST https://uatpayin.imoneypg.com/api/v3/payin/bank/init/ \
-H "API-KEY: 197eaa2042e8" \
-H "X-Timestamp: 1716374400" \
-H "X-Signature: d4e6f8..." \
-H "X-Merchant-Ref: ORDER-20260526-001" \
-H "X-Customer-IP: 203.0.113.45" \
-H "Content-Type: application/json" \
-d '{"data":"<encrypted_string>"}'{
"uid": "CUSTOMER-001",
"merchant_ref": "ORDER-20260526-001",
"amount": "1000.00",
"currency": "INR",
"payin_type": "UPI",
"customer_name": "Raj Kumar",
"customer_mobile": "9876543210",
"customer_email": "raj@example.com",
"customer_vpa": "raj@upi",
"description": "Order #ORD-12345"
}Step 2 — Execute #
Headers: API-KEY, X-Timestamp, X-Merchant-Ref, Content-Type: application/json
No X-Signature on execute — the AES-GCM authentication tag on the encrypted body provides integrity.
X-Merchant-Ref must match the merchant_ref used in the init call for this transaction_id.
The execute response is AES-256-GCM encrypted. Decrypt the data field using the same key and wire format as Section 5.1–5.2. The examples below show the decrypted content.
Request Fields
| Field | Type | Required | Rule |
|---|---|---|---|
transaction_id | string | Yes | From Step 1 |
amount | string | Yes | Must exactly match Step 1 |
payin_type | string | Yes | Must exactly match Step 1 |
Response — Pending Customer Action
{
"response_code": "001",
"message": "Transaction pending",
"data": {
"pg_ref": "PG20260526PAY001A",
"transaction_id": "220260526143022123456",
"payment_details": {
"vpa": "merchant@upi",
"qr_code": "<base64_or_svg_string>",
"upi_link": "upi://pay?pa=merchant@upi&am=1000.00&cu=INR"
}
}
}Show qr_code or upi_link to your customer. Do not mark as final. Store pg_ref for webhook reconciliation.
⚠️ transaction_id consumption rule: The transaction_id is consumed the moment it is used in execute — whether or not a mismatch error is returned. On any mismatch (amount, payin_type) you must generate a new merchant_ref and restart from Step 1.
Error Responses — Execute
| Scenario | response_code | message (example) |
|---|---|---|
| Rate limit exceeded | 103 | "Rate limit exceeded" · HTTP 429 |
| Timestamp missing or outside ±5 min window | 108 | "X-Timestamp expired or missing." |
Missing X-Merchant-Ref header | 104 | "X-Merchant-Ref header is missing." |
| Security key unavailable (transient) | 101 | "Security key unavailable. Retry in a moment." · HTTP 503 |
| Body decryption failed | 110 | "Failed to decrypt request payload." |
| A field sent as non-string type | 104 | "Invalid type for field \"amount\": must be sent as a JSON string." |
transaction_id missing from body | 111 | "transaction_id is required. Call /init/ first." |
| Required field missing | 104 | "Missing required field: amount" |
| Invalid amount format | 106 | "Invalid amount: ..." |
transaction_id not found in DB | 111 | "transaction_id not found. Call /init/ first." |
transaction_id already used (double execute) | 111 | "transaction_id has already been used. Your merchant_ref (ORDER-XXX) is reserved — generate a new merchant_ref and call /init/ again." |
transaction_id expired (5-min window elapsed) | 111 | "transaction_id expired (5-minute window). Your merchant_ref (ORDER-XXX) is reserved — generate a new merchant_ref and call /init/ again." |
X-Merchant-Ref does not match transaction | 104 | "X-Merchant-Ref header \"REF-A\" does not match the merchant_ref for this transaction." |
amount does not match init amount | 106 | "Amount mismatch: init declared 1000.00, execute sent 900.00. This transaction_id is now consumed. Generate a new merchant_ref and call /init/ again with the correct amount." |
payin_type does not match init type | 104 | "payin_type mismatch: init declared INTENT, execute sent UPI. This transaction_id is now consumed." |
| Merchant route credentials missing at execute time | 105 | "Merchant route is not fully configured (acquirer credentials missing)." |
| No acquirer linked to the payment record | 105 | "No acquirer configured for this payment." |
| No bank handler implemented for this acquirer+type | 105 | "No payment handler for AcquirerName_INTENT." |
| Payment gateway call raised an exception | 101 | "Payment gateway error [ref:XXXXXXXX]. Do NOT retry. Transaction (ORDER-XXX) remains Pending — share ref:XXXXXXXX with ops to investigate." · Do not retry. Keep Pending. Contact ops with the ref code. |
Status Check #
Base URL is different from the Open API. Status check uses payinstatus.imoneypg.com (Production) or uatpayinstatus.imoneypg.com (UAT) — not the init/execute domain.
Headers: API-KEY, X-Timestamp, X-Signature, X-Merchant-Ref
Signature formula for the pg_ref variant: ref_id = pg_ref, amount = "", body_bytes = b"".
Signature formula for the merchant_ref variant: ref_id = merchant_ref, amount = "", body_bytes = b"".
X-Merchant-Ref must match the merchant_ref on the transaction. For the status-by-pg_ref variant it is validated against the DB record; for status-by-merchant_ref it must equal the URL parameter.
Success responses are AES-256-GCM encrypted. Decrypt the data field using the same key and wire format as Section 5.1–5.2. Error responses (auth, timestamp, signature) are returned as plain JSON.
Request
curl -X GET "https://payinstatus.imoneypg.com/api/v3/payin/status/PG20260526PAY001A/" \
-H "API-KEY: 9b80b436a5ff" \
-H "X-Timestamp: 1716374700" \
-H "X-Signature: <computed_sig>" \
-H "X-Merchant-Ref: ORDER-20260526-001"Response — Invalid Signature
{
"response_code": "109",
"message": "X-Signature verification failed."
}Response — Success (decrypted)
{
"response_code": "000",
"message": "Transaction successful",
"data": {
"pg_ref": "PG20260526PAY001A",
"merchant_ref": "ORDER-20260526-001",
"status": "Success",
"captured_amount": "1000.00",
"customer_utr": "CUT20260526999"
}
}Blocked for disputed transactions — returns response_code: 113. Await the payin.dispute_resolved webhook.
Status check is available immediately after execute — you may call it as soon as ~5 seconds after Step 2. Poll every 5–10 seconds for up to 5 minutes while the customer is on the payment screen. After 5 minutes with no final status, use Resend Callback to force webhook re-delivery, then escalate if still unresolved after 30 minutes.
Resend Callback # NEW · v1.5
Use this endpoint to manually re-trigger webhook delivery for a transaction whose webhook was not received (e.g. your server was down, the URL was misconfigured, or all automatic retry attempts were exhausted).
Headers: API-KEY, X-Timestamp, X-Merchant-Ref
Rate-capped at 10 resend attempts per transaction across all calls. Once the cap is reached, no further resends are accepted — escalate to support.
Blocked during disputes. If the transaction is under dispute, this endpoint returns response_code: 113. Use the dispute webhook flow instead.
Not a status check. This endpoint triggers a new webhook delivery — it does not return the transaction status inline. The webhook will arrive at your registered webhook URL.
Request
curl -X POST "https://payinstatus.imoneypg.com/api/v3/payin/resend-callback/PG20260526PAY001A/resend/" \
-H "API-KEY: 9b80b436a5ff" \
-H "X-Timestamp: 1716375000" \
-H "X-Merchant-Ref: ORDER-20260526-001"No request body required — the body may be empty or omitted.
Response — Accepted
{
"response_code": "000",
"message": "Callback queued for delivery.",
"data": {
"pg_ref": "PG20260526PAY001A"
}
}response_code: 000 means the webhook has been queued — not that it has been delivered. Allow up to 30 seconds for delivery to your endpoint.
Error Responses — Resend Callback
| Scenario | response_code | Notes |
|---|---|---|
| Rate limit exceeded | 103 | HTTP 429 |
| Timestamp missing or outside ±5 min window | 108 | Plain JSON |
Missing X-Merchant-Ref header | 104 | Plain JSON |
| Security key unavailable (transient) | 101 | HTTP 503 |
pg_ref not found | 404 | "Transaction not found." |
X-Merchant-Ref does not match the transaction | 104 | Plain JSON |
| Transaction is under dispute | 113 | "Transaction is under dispute. Resend callback is not available. Await payin.dispute_resolved webhook." |
| Resend cap reached (> 10 attempts) | 102 | "Resend limit reached for this transaction. Contact support." |
| Transaction not yet in a final state | 001 | "Transaction is still pending. Callback will be sent automatically when the transaction reaches a final state." |
Action by Response
| Response / Condition | What It Means | What to Do Next |
|---|---|---|
response_code: 000 | Webhook queued | Wait up to 30 seconds for it to arrive at your webhook endpoint. If it still doesn't arrive, check your endpoint is reachable and correct. |
response_code: 001 — still pending | Transaction not yet final | Do not resend. The webhook will arrive automatically when the payment reaches a final state. Keep Pending. |
response_code: 113 — under dispute | Dispute active | Do not resend. Await payin.dispute_resolved webhook. |
response_code: 102 — cap reached | 10 resend limit exhausted | Escalate to iMoney support. Do not attempt further resends. |
response_code: 404 — not found | Wrong pg_ref | Verify the pg_ref value. Check your local records. |
response_code: 104 — merchant_ref mismatch | X-Merchant-Ref doesn't match | Send the same merchant_ref that was used during Init for this pg_ref. |
Balance Check # NEW · v1.1
Read your current available balance held with iMoney. Useful for dashboards, reconciliation tooling, and balance monitoring.
Headers: API-KEY, X-Timestamp, X-Signature, X-Merchant-Ref
Signature formula: ref_id = "", amount = "", body_bytes = b"" — all three inputs are empty strings/bytes.
X-Merchant-Ref must be present, but any valid non-empty value is accepted — there is no transaction to validate against. We recommend a fixed marker such as BALANCE-CHECK.
The success response is AES-256-GCM encrypted. Decrypt the data field using Section 5.1–5.2.
Request
curl -X GET "https://payinstatus.imoneypg.com/api/v3/payin/balance/" \
-H "API-KEY: 9b80b436a5ff" \
-H "X-Timestamp: 1716374700" \
-H "X-Signature: <computed_sig>" \
-H "X-Merchant-Ref: BALANCE-CHECK"Response — Success (decrypted)
{
"status": "success",
"response_code": "000",
"data": {
"balance": "25000.00",
"currency": "INR"
},
"message": "",
"timestamp": "1716374700"
}Response — Authentication Error
{
"status": "error",
"response_code": "401",
"data": null,
"message": "API-KEY is not recognised.",
"timestamp": "1716374700"
}Response Code Reference #
All responses return HTTP 200, except:
- HTTP 429 —
response_code: 103(rate limit) - HTTP 503 —
response_code: 101(security key unavailable at gateway boot)
Authentication failures (bad API-KEY, unregistered IP) are returned by the permission layer as HTTP 403 with a plain JSON error body — not a response_code in the business sense.
| Code | Meaning | Endpoint(s) | Retryable? | Action |
|---|---|---|---|---|
| 000 | Success | All | No | Terminal success — init/balance OK, or transaction is Success |
| 001 | Pending — awaiting customer payment | Execute | No — await webhook | Normal post-execute state. Show payment screen. Await webhook. |
| 002 | Initiated — customer started payment | Status check | No — await webhook | Intermediate state. Keep pending. Await webhook. |
| 003 | Failed — payment failed or rejected | Status check | Yes — new merchant_ref | Terminal failure confirmed. Allow customer to retry. |
| 100 | Insufficient wallet balance | Init | No | Prepaid: wallet doesn't cover estimated charges + buffer. Postpaid: balance below safety floor. Top up or contact ops. |
| 101 | Internal / transient error | All | Once after 60s | Retry once, then contact support. Execute exception: do NOT retry — keep Pending and share the ref code with ops. |
| 102 | Duplicate merchant_ref | Init | No | Reference already used. Generate a fresh unique merchant_ref. |
| 103 | Rate limit exceeded | All · HTTP 429 | Yes, reduce frequency | Slow down. Max 10 req/s per IP. |
| 104 | Invalid/missing field or header mismatch | Init, Execute | After fix | Read message — it names the exact field and cause. |
| 105 | Route unavailable or not configured | Init, Execute | No | Own-Route: route not found, inactive, or credentials missing. Platform-Route: no platform route matches. Contact iMoney Ops. |
| 106 | Invalid amount or amount mismatch | Init, Execute | After fix | At init: format wrong, or below/above route limits. At execute: amount doesn't match init — transaction_id is consumed, start over. |
| 107 | Currency not supported or wrong case | Init | No | Use exact format e.g. INR (uppercase). |
| 108 | Timestamp expired or missing | All | Yes | Sync NTP. ±5 min window. |
| 109 | X-Signature verification failed | Init, Status, Balance | After fix | Recompute with the correct per-endpoint formula (see Signature). |
| 110 | Decryption failed | Init, Execute | After fix | Verify hash_key and your AES-256-GCM implementation. |
| 111 | transaction_id invalid, expired, or already used | Execute | Yes — re-initiate | Call Step 1 with a new merchant_ref. Old merchant_ref may be reserved. |
| 112 | Transaction blocked by fraud engine | Init | No | Contact iMoney support. Do not retry automatically. |
| 113 | Under dispute — status/resend blocked | Status check | No | Await payin.dispute_resolved webhook. |
| 115 | Payment type not recognised | Init | Yes — after fix | payin_type not in iMoney's system. Check valid types in the error message. |
| 116 | Payment type not enabled on this route | Init (Own-Route only) | No | Your route doesn't support this payin_type. Contact iMoney to enable it, or use a different type. |
| 404 | Transaction not found | Status check | No | Verify pg_ref or merchant_ref. |
Edge / CloudFront Error Codes # NEW · v1.2.2
iMoney's APIs sit behind a CloudFront CDN edge. The HTTP status codes listed below are returned by CloudFront itself — they mean the request never reached our backend, so no response_code JSON body will be present. These are categorically different from the response_code values inside our normal JSON responses (Section Response Code Reference).
If you receive any of the HTTP statuses below, do NOT mark the transaction as Success or Failed on your side. CloudFront did not process the request, but in rare situations a transaction may still have been initiated elsewhere on the network. Always check with iMoney's Operations team before changing the transaction state. Reach out via techpg@imoneypg.com with merchant_ref, pg_ref (if you have it), the exact timestamp, and the full HTTP response.
4xx — Client-Side / Request Errors
| HTTP | Meaning | What it usually means | Action |
|---|---|---|---|
| 400 | Bad Request | Malformed HTTP request — invalid headers, malformed JSON envelope, broken encoding. | Have your developer review the raw HTTP request; check headers, body bytes, and content-type. |
| 403 | Forbidden | Request was actively rejected at the edge — typically wrong API base URL, IP not allowed, or geographic restriction (see India-Only Policy). | Verify base URL matches the environment (UAT vs Production) and that the request originates from an Indian IP. |
| 404 | Not Found | The endpoint path doesn't exist at the edge — likely a typo or a removed/renamed endpoint. | Recheck the endpoint path. See Environments for current URLs. |
| 405 | Method Not Allowed | Wrong HTTP verb (e.g. GET on a POST endpoint). | Check the method in our docs — init/execute are POST; status/balance are GET. |
| 414 | Request-URI Too Long | The URL itself exceeds CloudFront's limits — typically caused by giant query strings. | Move parameters into the request body. Keep URLs short. |
| 416 | Range Not Satisfiable | A malformed Range header was sent. | Remove the Range header — iMoney's APIs do not use range requests. |
5xx — Edge / Upstream Errors
| HTTP | Meaning | What it usually means | Action |
|---|---|---|---|
| 500 | Internal Server Error | CloudFront hit an unexpected internal error before reaching iMoney. | Retry once after 60 seconds. If it persists, contact techpg@imoneypg.com. |
| 501 | Not Implemented | CloudFront does not implement the requested HTTP feature (e.g. an unknown method). | Use only standard methods (GET, POST). Contact support if you see this with a normal request. |
| 502 | Bad Gateway | iMoney's origin returned an invalid response to CloudFront — usually a deploy hiccup or upstream issue. | Keep transaction as Pending. Wait 5 min, then call Status Check to confirm. |
| 503 | Service Unavailable | Origin is temporarily down or rate-limited. | Keep transaction as Pending. Back off, retry after a few minutes, then verify via Status Check. |
| 504 | Gateway Timeout | Origin took too long to respond — request may or may not have been processed. | Keep transaction as Pending. Wait 5 min, then verify via Status Check. Do not retry an execute call. |
response_codeThe codes above are HTTP status codes emitted at the CloudFront edge — they are different from the response_code values you'll find inside a normal iMoney JSON response (which always travel on HTTP 200). Don't confuse the two when wiring up your error-handling switch-cases.
Transaction Status Reference #
| Status | Meaning | Terminal? | Your Action |
|---|---|---|---|
| Pending | Awaiting customer action | No | Keep Pending, await webhook |
| Initiated | Customer started payment flow | No | Keep Pending, await webhook |
| Success | Payment received and confirmed | Yes | Release order / credit customer |
| Failed | Payment failed or rejected | Yes | Allow customer retry |
| Expired | Customer didn't pay in time | Yes | Allow retry |
When to Mark Success, Failed, or Pending #
The one rule: only a webhook carrying a final transaction_status, or explicit written confirmation from iMoney support, may move a transaction out of Pending. Everything else — errors, timeouts, unfamiliar codes, your own exceptions — keeps the transaction Pending until resolved.
The One Decision You Must Get Right — Execute
Execute called
│
├── Got response_code + message?
│ │
│ ├── 001 ──────────────────────────────────────▶ PENDING — show UI, await webhook
│ ├── 104/106/110/111 (not "already used") ─────▶ FAILED — no payment, restart with new merchant_ref
│ ├── 101 with ref:XXXXXXXX ───────────────────▶ PENDING — contact ops, do NOT retry
│ └── anything else ──────────────────────────▶ PENDING — check status after ~5 seconds
│
└── No response (timeout / 5xx / your exception)?
│
└─────────────────────────────────────────────▶ PENDING — check status after ~5 seconds, do NOT retry execute
When Retry Init Is Safe
You may only call Init again (with a new merchant_ref) when you have confirmed one of:
- iMoney returned an error that definitively means no payment was created (codes
104,106mismatch,110,111expired/not-found). - Status check by
merchant_refreturned404— the transaction does not exist in iMoney's system. - iMoney support explicitly tells you the transaction was not processed.
In all other cases, keep Pending and investigate before creating a new transaction.
Master Decision Table — By Signal Source
Definitive guide for every signal your integration will encounter.
| Scenario | Mark As | Notes |
|---|---|---|
Execute returns 001 | Pending | Standard post-execute. Await webhook. |
Execute returns 102 | Failed | Duplicate ref — definitive. |
Execute returns 104 | Failed | Invalid field — fix and retry. |
Execute returns 111 | Failed | Re-init with fresh merchant_ref. |
Execute returns 110 | Failed | Fix encryption. |
| Other unknown error | Pending | Uncertain — contact support. |
Execute returns 101 with ref:XXXXXXXX | Pending | Payment gateway error. Do NOT retry. Contact ops with the ref code. Transaction remains Pending. |
| HTTP 429 from gateway | Pending | Rate limit — slow down and retry. Do not mark Failed. |
| HTTP 503 from gateway | Pending | Gateway key unavailable. Retry after 60 seconds. |
| HTTP 500 / 502 / 504 from gateway | Pending | Infrastructure error. Do not retry. Check status after 5 minutes. |
| Connection timeout | Pending | Unknown if request was received. |
Webhook: Success | Success | Authoritative. Release order. |
Webhook: Failed | Failed | Allow retry with new order. |
Webhook: Expired | Expired | Allow retry. |
Status returns 000 | Success | Use as reconciliation. |
| No webhook after 24h | Escalate | Contact techpg@imoneypg.com. |
Key rule: Only a webhook with a final transaction_status or explicit iMoney support confirmation moves a transaction out of Pending.
Unexpected Scenarios — Always Keep Pending #
When something unexpected happens — network error, ambiguous response, exception in your code, timeout — always keep the transaction Pending.
iMoney may have processed the request even if you didn't get a response. Marking Failed and allowing a retry could double-charge your customer.
Keep transaction as Pending
Persist the state. Do not auto-fail.
Record details
Save merchant_ref and the exact error.
Wait 5 minutes
Then call status check.
Reconcile
000 → Success; 003 → Failed; not found → safe to retry.
Escalate if still uncertain
Contact iMoney support with merchant_ref and timestamp.
Webhook Integration #
Delivery Mechanism
When a payin reaches a final state, iMoney sends an encrypted HTTP POST to your webhook URL.
- Webhook callbacks originate from the dedicated webhook subdomain:
Production:payinhooks.imoneypg.com· UAT:uatpayinhooks.imoneypg.com - The TCP source IP is always one of iMoney's two static GA IPs:
166.117.126.71or99.83.230.145. Whitelist both. - iMoney attempts delivery 5 times total (1 initial + 4 retries). The retry schedule is fixed: immediate → 30 sec → 2 min → 10 min → 1 hr. After all 5 attempts are exhausted, no further retries occur.
- Your endpoint must be idempotent — process each
pg_refexactly once.
Webhook Payload #
iMoney sends a POST with these headers:
Content-Type: application/json X-Webhook-Timestamp: <unix_epoch> X-Pg-Ref: <pg_ref> X-Api-Key: <your_api_key> X-Merchant-Ref: <your_merchant_ref>
Raw body
{
"encrypted": "<nonce_hex>:<ciphertext_hex>:<tag_hex>",
"timestamp": "1716374800",
"pg_ref": "PG20260526PAY001A"
}Decrypted payload — Success
{
"event": "payin.status_update",
"data": {
"pg_ref": "PG20260526PAY001A",
"merchant_ref": "ORDER-20260526-001",
"transaction_status": "Success",
"amount": "1000.00",
"captured_amount": "1000.00",
"payin_method": "INTENT",
"customer_utr": "CUT20260526999",
"customer_upi": "user@upi",
"bank_ref": "UTR123456789",
"acquirer_ref": "ACQ20260526001",
"currency": "INR",
"created_at": "2026-05-26T10:05:00+05:30",
"updated_at": "2026-05-26T10:05:30+05:30"
}
}Event: payin.test — Test Webhook NEW · v1.1
Fired from the iMoney merchant dashboard when you use the "Send Test Webhook" function. No action is required — just decrypt to confirm your endpoint is wired correctly, then return HTTP 200.
{
"event": "payin.test",
"data": {
"pg_ref": "TEST-PG-REF",
"merchant_ref": "TEST-MERCHANT-REF",
"message": "This is a test webhook from iMoney. No action required."
}
}Use this to validate your webhook plumbing (TCP whitelisting, decryption, ack with HTTP 200) without affecting any real order.
Decrypted payload fields
| Field | Type | Description |
|---|---|---|
pg_ref | string | iMoney's unique transaction reference |
merchant_ref | string | Your order reference from the init request |
transaction_status | string | Final status: Success, Failed, or Expired |
amount | string | The amount from the init request |
captured_amount | string | Actual amount received — may differ. Use this for reconciliation. |
payin_method RENAMED in v1.5 | string | Payment method used by the customer: UPI, INTENT, QR, NETBANKING (was payin_type in v1.4 and earlier) |
customer_utr | string | UTR/reference from the customer's bank — empty if payment did not complete |
customer_upi | string | Customer's UPI VPA — empty if not collected |
bank_ref | string | iMoney's bank-side reference — empty if payment did not complete |
acquirer_ref NEW | string | Acquiring bank reference — empty if payment did not complete |
currency | string | Currency code |
created_at | string | Transaction creation time (ISO 8601) |
updated_at | string | Last update time (ISO 8601) |
Acknowledging the Webhook #
Your endpoint must return HTTP 200 immediately — before processing.
Correct pattern: acknowledge first, process asynchronously.
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
@csrf_exempt
def payin_webhook(request):
job_queue.enqueue(process_webhook, request.body)
return HttpResponse("OK", status=200)app.post('/webhook/payin', express.raw({ type: 'application/json' }), (req, res) => {
jobQueue.add({ body: req.body });
res.status(200).send('OK');
});<?php
$body = file_get_contents('php://input');
enqueueWebhook($body);
http_response_code(200);
echo 'OK';
exit;Trusting & Verifying the Webhook #
Two verification layers — use both:
Source IP verification
Only accept POSTs from 166.117.126.71 or 99.83.230.145. Any other IP is suspicious — drop and report.
Payload decryption
AES-256-GCM is authenticated — decryption fails on tampering. If it succeeds, the payload is authentic.
Verification checklist
FUNCTION verify_and_process_webhook(source_ip, raw_body):
// Step 1: IP check — only iMoney's two GA IPs
IMONEY_TRUSTED_IPS = ["166.117.126.71", "99.83.230.145"]
IF source_ip NOT IN IMONEY_TRUSTED_IPS:
LOG "SUSPICIOUS: webhook from " + source_ip
ALERT techpg@imoneypg.com
RETURN HTTP 403
// Step 2: Parse + decrypt
envelope = JSON_DECODE(raw_body)
TRY:
payload = decrypt_webhook(envelope["encrypted"], api_key, hash_key)
CATCH DecryptionError:
LOG "Decryption failed — possible tampering"
RETURN HTTP 200 // stop retries, do not process
// Step 3: Idempotency
txn = database.find(payload["data"]["pg_ref"])
IF txn is None OR txn.is_final_state():
RETURN HTTP 200
// Step 4: Process
UPDATE txn.status = payload["data"]["transaction_status"]
IF status == "Success": release_order(txn)
ELSE: allow_retry(txn)
RETURN HTTP 200
Decrypting the Webhook #
Same key derivation and wire format as outbound requests.
def decrypt_webhook(encrypted_str, api_key, hash_key):
nonce_hex, ct_hex, tag_hex = encrypted_str.strip().split(':')
key = derive_key(api_key, hash_key)
nonce = bytes.fromhex(nonce_hex)
ct = bytes.fromhex(ct_hex)
tag = bytes.fromhex(tag_hex)
plain = AESGCM(key).decrypt(nonce, ct + tag, None)
return json.loads(plain)function decryptWebhook(encryptedStr, apiKey, hashKey) {
const [nh, ch, th] = encryptedStr.trim().split(':');
const nonce = Buffer.from(nh, 'hex');
const ct = Buffer.from(ch, 'hex');
const tag = Buffer.from(th, 'hex');
const key = deriveKey(apiKey, hashKey);
const d = crypto.createDecipheriv('aes-256-gcm', key, nonce);
d.setAuthTag(tag);
return JSON.parse(Buffer.concat([d.update(ct), d.final()]).toString());
}function decryptWebhook($encryptedStr, $apiKey, $hashKey): array {
[$nh, $ch, $th] = explode(':', trim($encryptedStr));
$key = deriveKey($apiKey, $hashKey);
$plain = openssl_decrypt(hex2bin($ch), 'aes-256-gcm', $key,
OPENSSL_RAW_DATA, hex2bin($nh), hex2bin($th));
if ($plain === false) throw new Exception('Decryption failed');
return json_decode($plain, true);
}public static Map decryptWebhook(String encryptedStr, String apiKey, String hashKey) throws Exception {
String[] parts = encryptedStr.trim().split(":");
byte[] nonce = hexToBytes(parts[0]);
byte[] ct = hexToBytes(parts[1]);
byte[] tag = hexToBytes(parts[2]);
byte[] key = deriveKey(apiKey, hashKey);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce));
byte[] ctTag = new byte[ct.length + tag.length];
System.arraycopy(ct, 0, ctTag, 0, ct.length);
System.arraycopy(tag, 0, ctTag, ct.length, tag.length);
return parseJson(new String(cipher.doFinal(ctTag), "UTF-8"));
}Dispute Webhook Flow #
How Disputes Differ from Normal Flow
A dispute opens when a previously-terminal transaction (e.g. Success) is contested. Runs on a separate webhook track.
- No further normal-flow webhooks for the disputed
pg_ref. - Dispute events:
payin.dispute_raisedandpayin.dispute_resolved. - Status check is blocked → returns
113. - Resend callback is blocked → returns
113. - Same encryption, same trusted IPs (
166.117.126.71,99.83.230.145), same HTTP 200 ack.
Dispute Raised Webhook #
X-Webhook-Timestamp: <unix_epoch> X-Pg-Ref: <pg_ref> X-Api-Key: <your_api_key> X-Merchant-Ref: <your_merchant_ref> X-Dispute-Id: <dispute_id> X-Event: payin.dispute_raised
{
"event": "payin.dispute_raised",
"data": {
"pg_ref": "PG20260526PAY001A",
"merchant_ref": "ORDER-20260526-001",
"dispute_id": "DSP20260526001",
"dispute_reason": "Customer claims payment not received",
"dispute_category": "CHARGEBACK",
"previous_status": "Success",
"current_status": "Dispute",
"amount": "1000.00",
"currency": "INR",
"raised_at": "2026-05-26T14:00:00+05:30"
}
}Dispute Resolved Webhook #
Carries the financial settlement detail.
Merchant Loss (chargeback upheld)
{
"event": "payin.dispute_resolved",
"data": {
"pg_ref": "PG20260526PAY001A",
"dispute_id": "DSP20260526001",
"previous_status": "Dispute",
"current_status": "Failed",
"resolution": "MERCHANT_LOSS",
"resolution_remark": "Chargeback upheld by customer bank",
"amount": "1000.00",
"not_settled_amount": "1000.00",
"charges": "25.00",
"debit": "1025.00",
"credit": "0.00",
"currency": "INR",
"resolved_at": "2026-05-28T10:30:00+05:30"
}
}Merchant Win (dispute rejected)
{
"event": "payin.dispute_resolved",
"data": {
"pg_ref": "PG20260526PAY001A",
"dispute_id": "DSP20260526001",
"previous_status": "Dispute",
"current_status": "Success",
"resolution": "MERCHANT_WIN",
"resolution_remark": "Dispute rejected — original payment confirmed valid",
"amount": "1000.00",
"not_settled_amount": "0.00",
"charges": "0.00",
"debit": "0.00",
"credit": "1000.00",
"currency": "INR",
"resolved_at": "2026-05-28T10:30:00+05:30"
}
}When to Mark Status During a Dispute #
| Event | Mark As | Notes |
|---|---|---|
payin.dispute_raised | Dispute | Overrides previous status. |
Resolved + Success | Success | Merchant win. |
Resolved + Failed | Failed | Merchant loss. Record debit/charges. |
Resolved + Partial | Partial | Record not_settled_amount. |
| No resolved after 7 days | Escalate | Contact support with dispute_id. |
Escalation & Support #
When escalating, always provide: merchant_ref, pg_ref, exact timestamp, and the full response/error received.
Level 1 Support
Integration help, webhooks, encryption, general technical issues.
Level 2 Support
Escalations from L1, stuck transactions, urgent production issues.
Merchant Onboarding
New merchant onboarding, KYC, account activation, routes.
www.imoneypay.in — product information, pricing, contact forms.
If you receive a request claiming to be from iMoney from an IP other than 166.117.126.71 or 99.83.230.145, report it immediately to techpg@imoneypg.com with the source IP and full headers.
If you hit a scenario, error message, or edge-case that isn't documented above, do not improvise — contact our support team immediately at techpg@imoneypg.com for a fast resolution. Payment systems evolve and surprising things sometimes happen on the network or acquirer side. Always keep your integration up to date — whenever we change anything in the API surface, this documentation is updated first and a new entry is published in the Changelog with the new version number. Subscribe to techpg@imoneypg.com's release mailing list to be notified the moment a new version ships.
🛑 If you ever receive an unexpected or unrecognised response from us — stop sending traffic to iMoney immediately and contact support right away at techpg@imoneypg.com. Continuing to send transactions during such windows can lead to duplicate charges, stuck transactions, or reconciliation gaps. Pause first, escalate, then resume only after a written go-ahead from our tech team.
UAT Sign-off — Mandatory #
Every merchant must complete a UAT Sign-off with I Money Pay Private Limited. If you have not received the form, write to onboarding@imoneypay.in or techpg@imoneypg.com and request the UAT Sign-off Form. Signing it confirms that you have successfully configured all APIs and your error handling is implemented correctly.
Without a completed UAT Sign-off, I Money Pay Private Limited and its Tech Team will not be held responsible for any losses, mis-handled transactions, or integration defects on the merchant side.
This document is confidential and intended solely for the named merchant. iMoney reserves the right to update API specifications. Breaking changes will be communicated separately with a migration guide.