iMoney PA-O Integration
Collect payments from your customers via UPI, QR, Intent, and Net Banking. Built for developers — 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. The integration follows a two-step Init → Execute flow, after which iMoney notifies your server of the final outcome via webhook.
🔐 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
| Environment | Base URL | Purpose |
|---|---|---|
| UAT | https://uatpayin.imoneypg.com | Sandbox — use during integration & testing |
| Production | https://payin.imoneypg.com | Live — for real customer transactions |
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 — never use it as the primary way to determine final transaction status.
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, merchant type (Prepaid or Non-Prepaid), and route name (Prepaid only). Whitelist your outbound server IP.
2. Implement encryption
Implement key derivation (SHA256(api_key + hash_key)) and AES-256-GCM encryption for request bodies. See the Encryption section for ready-to-use code in Python, Node.js, PHP and Java.
3. Call Init → Execute
Step 1: POST /api/v3/payin/init/ (or /bank/init/) to start a transaction. Step 2: POST /api/v3/payin/ to execute and receive 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 | 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 Prepaid merchants | iMoney Onboarding |
| Merchant Type | Prepaid or Non-prepaid — determines which init endpoint to use | iMoney Onboarding |
| iMoney Webhook IP | The static IP from which iMoney sends all webhook callbacks — whitelist this on your server | iMoney Onboarding |
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.).
Merchant Types #
Your account is provisioned as one of two types. Calling the wrong init endpoint returns error 104.
| Type | Description | Init Endpoint |
|---|---|---|
| Prepaid | You supply a route_name from your assigned MerchantAcquirerAccount. iMoney routes through that specific acquirer. | POST /api/v3/payin/init/ |
| Non-Prepaid | iMoney automatically selects the best available platform acquirer — no route_name needed. | POST /api/v3/payin/bank/init/ |
The execute endpoint is the same for both types: POST /api/v3/payin/. If you are unsure of your merchant type, check your onboarding email or contact tech-support@imoneypg.com.
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 on every request
| Header | Required On | Format | Example |
|---|---|---|---|
API-KEY | All endpoints | Plain string | 9b80b436a5ff |
X-Timestamp | All endpoints | Unix seconds as string | 1716374400 |
X-Signature | Init endpoints only | Lowercase hex | a3f8c2… |
X-Merchant-Ref | Init endpoints | Your order/merchant reference | ORDER-20260526-001 |
X-Customer-IP | Init endpoints | End customer's real IP address | 203.0.113.45 |
Content-Type | POST endpoints | Fixed | application/json |
Keep your server clock synced via NTP. A timestamp drift greater than ±5 minutes returns response_code: 108.
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(); // Buffer
}
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); // raw binary
}
function deriveKeyHex(string $apiKey, string $hashKey): string {
return hash('sha256', $apiKey . $hashKey); // hex string
}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 request payloads must be AES-256-GCM encrypted and wrapped in a JSON envelope before sending.
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]);
}import javax.crypto.Cipher;
import javax.crypto.spec.*;
import java.security.SecureRandom;
import java.util.Arrays;
public static String encryptPayload(String jsonPayload, 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(jsonPayload.getBytes("UTF-8"));
byte[] ct = Arrays.copyOf(enc, enc.length - 16);
byte[] tag = Arrays.copyOfRange(enc, enc.length - 16, enc.length);
String encStr = bytesToHex(nonce) + ":" + bytesToHex(ct) + ":" + bytesToHex(tag);
return "{\"data\":\"" + encStr + "\"}";
}Computing the Signature #
The X-Signature header is required on all init endpoint calls. It proves that the request body has not been tampered with in transit.
Signature inputs
| Component | Value |
|---|---|
timestamp | The exact same value you send in X-Timestamp |
merchant_ref | Your unique order reference, exactly as it appears in the payload |
amount | Amount as a string exactly as in payload (e.g. "1000.00") |
body_bytes | The raw encrypted JSON bytes being sent as the HTTP body |
def compute_signature(api_key: str, hash_key: str, timestamp: str,
merchant_ref: str, amount: str, body_bytes: bytes) -> str:
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(string $apiKey, string $hashKey, string $timestamp,
string $merchantRef, string $amount, string $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 #
A complete end-to-end payin init function in Python — encrypts the payload, computes the signature, and posts to iMoney.
import hashlib, json, os, time
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import requests
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 ─────────────▶│ │
│ (POST /payin/init/ or │ │
│ POST /payin/bank/init/) │ │
│◀── transaction_id + pg_ref ──│ │
│ (valid for 5 minutes) │ │
│ │ │
│── Step 2: Execute ──────────▶│ │
│ (POST /api/v3/payin/) │ │
│◀── response_code: 001 ───────│ │
│ pg_ref, payment details │ │
│ │ │
│ [show QR / UPI link to customer] │
│ │◀──── customer pays ──────────│
│ │ │
│◀── WEBHOOK ──────────────────│ │
│ (final transaction_status)│ │
│ │ │
│ [if no webhook after 5 min]│ │
│── Status Check (fallback) ──▶│ │
│◀── response_code: 000/003 ───│ │
The webhook is the primary signal. Status check is a fallback only, and must not be called before 5 minutes have elapsed since execute.
Step 1 — Init (Prepaid 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. Cannot be reused. |
amount | string | Yes | Positive decimal, max 2 decimal places (e.g. "1000.00") |
currency | string | Yes | "INR" |
payin_type | string | Yes | UPI, INTENT, QR, NETBANKING — as supported on your route |
route_name | string | Yes | Your assigned route name — from iMoney onboarding |
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 address |
customer_vpa | string | No | End customer's UPI VPA (sent to iMoney FUSE for fraud screening) |
description | string | No | Order description |
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 (5 minutes) from this call. Call execute immediately. If the window expires, call init again — the old transaction_id cannot be used.
Response — Wrong Merchant Type
{
"response_code": "104",
"message": "This endpoint is for prepaid merchants. Non-prepaid merchants must use /api/v3/payin/bank/init/.",
"data": {}
}Step 1 — Init (Non-Prepaid Merchants) #
Same request fields as Prepaid, except route_name is not accepted — iMoney auto-selects the best available acquirer for the amount, currency, and payment type combination. If no matching route is available, response_code: 105 is returned.
Request
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"
}Response — No Route Available
{
"response_code": "105",
"message": "No available routes for UPI with amount 1000.00 INR.",
"data": {}
}Step 2 — Execute #
Headers: API-KEY, X-Timestamp, Content-Type: application/json
No X-Signature is required on the execute call. Use the transaction_id from Step 1. After execute, iMoney creates a payment entry and returns the QR code or UPI link for the customer.
Request Fields
| Field | Type | Required | Rule |
|---|---|---|---|
transaction_id | string | Yes | From Step 1 response |
amount | string | Yes | Must exactly match the amount from Step 1 |
payin_type | string | Yes | Must exactly match the payin_type from Step 1 |
Any mismatch between Step 1 and Step 2 fields is rejected. This is a security control — the init declaration is binding.
Request
curl -X POST https://uatpayin.imoneypg.com/api/v3/payin/ \
-H "API-KEY: 9b80b436a5ff" \
-H "X-Timestamp: 1716374450" \
-H "Content-Type: application/json" \
-d '{"data":"<encrypted_string>"}'{
"transaction_id": "220260526143022123456",
"amount": "1000.00",
"payin_type": "INTENT"
}Response — Accepted (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 the qr_code or upi_link to your customer. Do not mark the transaction as final at this stage. The response code 001 means the transaction is waiting for the customer to pay — not that anything went wrong.
Store the pg_ref immediately. You will need it to reconcile against the incoming webhook.
Status Check #
Headers: API-KEY, X-Timestamp
Do not call this endpoint before 5 minutes have elapsed since Step 2. The webhook is the primary notification mechanism. The status check is a fallback only — use it when no webhook has arrived after 5 minutes. Early calls will return response_code: 109.
If the transaction is currently under dispute, this endpoint is blocked and returns response_code: 113. Do not attempt to poll status for a disputed transaction — the dispute flow is the only active channel. Await the payin.dispute_resolved webhook.
Request
curl -X GET "https://uatpayin.imoneypg.com/api/v3/payin/status/PG20260526PAY001A/" \
-H "API-KEY: 9b80b436a5ff" \
-H "X-Timestamp: 1716374700"Response — Too Early
{
"response_code": "109",
"message": "Transaction is being processed. Retry status after 280 seconds.",
"data": { "retry_after_seconds": 280 }
}Response — Success
{
"response_code": "000",
"message": "Transaction successful",
"data": {
"pg_ref": "PG20260526PAY001A",
"merchant_ref": "ORDER-20260526-001",
"status": "Success",
"captured_amount": "1000.00",
"customer_utr": "CUT20260526999"
}
}Response — Failed
{
"response_code": "003",
"message": "Transaction failed",
"data": {
"pg_ref": "PG20260526PAY001A",
"merchant_ref": "ORDER-20260526-001",
"status": "Failed"
}
}Response — Under Dispute (Blocked)
{
"response_code": "113",
"message": "Transaction is under dispute. Status check and resend callback are not available. Await payin.dispute_resolved webhook.",
"data": {
"pg_ref": "PG20260526PAY001A",
"dispute_id": "DSP20260526001"
}
}Response Code Reference #
All responses return HTTP 200. Never use the HTTP status code for business logic. Always read response_code from the JSON body.
| Code | Meaning | Retryable | Action Required |
|---|---|---|---|
| 000 | Success | No | Terminal success — payment received and confirmed |
| 001 | Pending | No — await webhook | Normal state after execute. Customer payment in progress. Await webhook. |
| 002 | Initiated | No — await webhook | Payment flow started by customer. Intermediate state. Await webhook. |
| 003 | Failed | Yes — new merchant_ref | Payment failed. Safe to retry with a new merchant_ref. |
| 101 | Internal server error | Once, after 60s | Retry once after 60 seconds. If it persists, contact support. |
| 102 | Duplicate merchant_ref | No | This reference was already used. Generate a fresh unique merchant_ref. |
| 103 | Rate limit exceeded | Yes, reduce frequency | Slow down request rate per IP. |
| 104 | Invalid or missing field | Yes after correction | Read message for which field to fix. Retry from Step 1. |
| 105 | Route unavailable | No | No acquirer available for the given amount/type. Contact iMoney Ops. |
| 106 | Invalid amount / mismatch | Yes after correction | Amount format wrong or doesn't match init. Retry from Step 1. |
| 107 | Currency not supported | No | Contact iMoney to enable the required currency on your account. |
| 108 | Timestamp expired/missing | Yes | Sync server clock via NTP. X-Timestamp must be within ±5 minutes. |
| 109 | Status checked too early | Yes after retry_after_seconds | Wait the exact number of seconds given in retry_after_seconds, then retry. |
| 110 | Decryption failed | Yes after fix | Verify hash_key is correct and your encryption implementation is accurate. |
| 111 | Transaction ID invalid/expired | Yes — re-initiate | The transaction_id is expired or already used. Call Step 1 again. |
| 112 | Blocked by fraud engine | No | Contact iMoney support. Do not retry automatically. |
| 113 | Transaction under dispute | No | Status check and resend callback disabled. Await payin.dispute_resolved webhook. |
| 401 | Authentication error | No | API-KEY missing/not recognised, or server IP not whitelisted. Read message. |
| 404 | Transaction not found | No | Verify that pg_ref or merchant_ref is correct. |
Transaction Status Reference #
| Status | Meaning | Terminal? | Your Action |
|---|---|---|---|
| Pending | Payment awaiting customer action | No | Keep as Pending. Await webhook. Do not mark final. |
| Initiated | Customer has started the payment flow | No | Keep as Pending. Await webhook. Do not mark final. |
| Success | Payment received and confirmed by bank | Yes | Mark as Success. Release the order or credit the customer. |
| Failed | Payment failed or rejected | Yes | Mark as Failed. Allow customer to retry with a new order reference. |
| Expired | Customer did not pay in time | Yes | Mark as Failed/Expired. Allow retry. |
When to Mark Success, Fail, or Pending #
Use this table as the definitive decision guide for every state your integration will encounter.
| Scenario | Mark As | Notes |
|---|---|---|
Execute returns 001 | Pending | Standard post-execute state. Do not mark final. Await webhook. |
Execute returns 102 | Failed | Duplicate merchant_ref — definitive. No payment was initiated. |
Execute returns 104 | Failed | Invalid field — definitive. No payment was initiated. Fix and retry from Step 1. |
Execute returns 111 | Failed | Transaction ID expired before execute. Call init again with a fresh merchant_ref. |
Execute returns 110 | Failed | Decryption error — request was rejected. Fix encryption. |
| Execute returns any other error code | Pending | Uncertain state. Do not mark Failed. Contact support with merchant_ref. |
| HTTP 4xx from gateway | Pending | Request may not have reached iMoney. Check status after 5 minutes. |
| HTTP 5xx from gateway | Pending | Infrastructure error. Do not retry immediately. |
| Connection timeout during Step 2 | Pending | Network failure — unknown whether request was received. Check status after 5 min. |
Webhook: transaction_status: Success | Success | Terminal. Release order or credit customer. Authoritative signal. |
Webhook: transaction_status: Failed | Failed | Terminal. Allow customer to retry with a new order. |
Webhook: transaction_status: Expired | Failed/Expired | Terminal. Customer did not pay in time. Allow retry. |
Status API returns 000 | Success | Confirms the webhook signal. Use as reconciliation only. |
Status API returns 003 | Failed | Confirms failure. Use as reconciliation only. |
| No webhook after 24 hours | Escalate | Contact tech-support@imoneypg.com. Keep Pending until resolved. |
Key rule: Only two signals can move a transaction out of Pending — a webhook with a final transaction_status, or an explicit confirmation from iMoney support. Everything else keeps the transaction Pending.
Unexpected Scenarios — Always Keep Pending #
When something unexpected happens — a network error, an ambiguous response code, an exception in your own code during processing, a timeout — the correct action is always to keep the transaction in a Pending state.
iMoney may have received and processed the execute request even if your server never got the response. If you mark it Failed and allow a retry, your customer could be double-charged.
What to do in an unexpected scenario
Keep transaction as Pending
Persist the state. Do not auto-fail and do not auto-retry.
Record details
Save merchant_ref and the exact error or unexpected response you received for audit.
Wait 5 minutes
Wait at least 5 minutes before making any status check call.
Check status
Call GET /api/v3/payin/status/{pg_ref}/ (if you have pg_ref) or the merchant_ref variant.
Reconcile
If 000 → mark Success. If 003 → mark Failed. If not found → never created; safe to retry with same merchant_ref.
Escalate if still uncertain
If you still cannot determine the outcome, escalate to iMoney support with merchant_ref and exact timestamp.
Never retry an execute call without first confirming the original transaction's status.
Webhook Integration #
Delivery Mechanism
When a payin transaction reaches a final state (Success, Failed, or Expired), iMoney sends an encrypted HTTP POST to your registered webhook URL.
- Webhooks are sent from a single static IP assigned to iMoney's notification service. Whitelist this IP on your server firewall.
- iMoney retries the webhook up to 9 times with increasing intervals if your endpoint does not return HTTP 200.
- Retry schedule: immediate → 30 seconds → 2 minutes → 10 minutes → 1 hour, continuing until all retries are exhausted.
- The same webhook may arrive more than once. Your endpoint must be idempotent — process each
pg_refexactly once.
Webhook Payload #
iMoney sends a POST with Content-Type: application/json and the following 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>
Use X-Api-Key to confirm the webhook is addressed to your merchant account. Use X-Merchant-Ref to look up the order before decrypting the body.
Raw body (before decryption)
{
"encrypted": "<nonce_hex>:<ciphertext_hex>:<tag_hex>",
"timestamp": "1716374800",
"pg_ref": "PG20260526PAY001A"
}Decrypted payload
{
"event": "payin.status_update",
"data": {
"pg_ref": "PG20260526PAY001A",
"merchant_ref": "ORDER-20260526-001",
"transaction_status": "Success",
"amount": "1000.00",
"captured_amount": "1000.00",
"payin_type": "INTENT",
"customer_utr": "CUT20260526999",
"customer_upi": "user@upi",
"bank_ref": "UTR123456789",
"currency": "INR",
"created_at": "2026-05-26T10:05:00+05:30",
"updated_at": "2026-05-26T10:05:30+05:30"
}
}Fields in the decrypted payload
| 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 from amount. Use this for reconciliation. |
payin_type | string | Payment method used by the customer |
customer_utr | string | UTR/reference from the customer's bank |
bank_ref | string | iMoney's bank-side reference |
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 to acknowledge receipt. This is the only response iMoney looks at — the body content of your response is ignored.
Return HTTP 200 immediately — before any processing. If your downstream logic (database writes, order updates, sending emails) fails or takes too long, iMoney will interpret a non-200 or timeout as a delivery failure and retry.
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):
# Enqueue for async processing
job_queue.enqueue(process_webhook, request.body)
return HttpResponse("OK", status=200) # Return 200 immediatelyapp.post('/webhook/payin', express.raw({ type: 'application/json' }), (req, res) => {
jobQueue.add({ body: req.body });
res.status(200).send('OK'); // Return 200 immediately
});<?php
// Enqueue for async processing (use a queue like RabbitMQ, Redis, etc.)
$body = file_get_contents('php://input');
enqueueWebhook($body);
http_response_code(200);
echo 'OK';
exit;Trusting & Verifying the Webhook #
Do not trust a webhook just because it arrived at your endpoint. Any party who knows your webhook URL can send a POST to it. Two layers of verification — use both:
Source IP verification
iMoney sends webhooks exclusively from a dedicated static IP. Your firewall or app should reject any POST not originating from this IP.
Payload decryption verification
AES-256-GCM is authenticated encryption — decryption fails with an auth tag error if ciphertext or nonce was tampered with. If decryption succeeds, the payload is authentic.
Step-by-step verification checklist
FUNCTION verify_and_process_webhook(source_ip, raw_body):
// Step 1: IP check
IF source_ip NOT IN iMoney_static_ip_whitelist:
LOG "Rejected webhook from unknown IP: " + source_ip
RETURN HTTP 403 // or silently drop
// Step 2: Parse outer envelope
envelope = JSON_DECODE(raw_body)
IF envelope missing "encrypted", "pg_ref", "timestamp":
RETURN HTTP 400
// Step 3: Decrypt
TRY:
payload = decrypt_webhook(envelope["encrypted"], api_key, hash_key)
CATCH DecryptionError:
LOG "Webhook decryption failed — possible tampering"
RETURN HTTP 200 // Stop retries; do NOT process
// Step 4: Verify pg_ref exists in your system
txn = database.find_transaction(payload["data"]["pg_ref"])
IF txn is None:
RETURN HTTP 200 // Do not error-loop on unknown refs
// Step 5: Idempotency check
IF txn.is_final_state():
RETURN HTTP 200 // Already processed; acknowledge and skip
// Step 6: Process the status update
UPDATE txn.status = payload["data"]["transaction_status"]
IF transaction_status == "Success":
release_order_or_credit_customer(txn)
ELSE IF transaction_status in ["Failed", "Expired"]:
allow_customer_retry(txn)
RETURN HTTP 200
Why return HTTP 200 even when rejecting? For tampered payloads and unknown pg_ref entries, returning 200 prevents iMoney from endlessly retrying a webhook that will always fail your checks. Log the event for investigation instead.
Decrypting the Webhook #
The webhook encrypted field uses the same key derivation as your outbound requests: SHA256(api_key + hash_key). Wire format is nonce_hex:ciphertext_hex:tag_hex.
def decrypt_webhook(encrypted_str: str, api_key: str, hash_key: str) -> dict:
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 parts = encryptedStr.trim().split(':');
const nonce = Buffer.from(parts[0], 'hex');
const ct = Buffer.from(parts[1], 'hex');
const tag = Buffer.from(parts[2], '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(string $encryptedStr, string $apiKey, string $hashKey): array {
[$nonceHex, $ctHex, $tagHex] = explode(':', trim($encryptedStr));
$key = deriveKey($apiKey, $hashKey);
$plain = openssl_decrypt(
hex2bin($ctHex),
'aes-256-gcm',
$key,
OPENSSL_RAW_DATA,
hex2bin($nonceHex),
hex2bin($tagHex)
);
if ($plain === false) {
throw new Exception('Webhook decryption failed — authentication tag mismatch');
}
return json_decode($plain, true);
}public static Map<String, Object> 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[] ctWithTag = new byte[ct.length + tag.length];
System.arraycopy(ct, 0, ctWithTag, 0, ct.length);
System.arraycopy(tag, 0, ctWithTag, ct.length, tag.length);
byte[] plain = cipher.doFinal(ctWithTag);
return parseJson(new String(plain, "UTF-8"));
}Dispute Webhook Flow #
How Disputes Differ from Normal Flow
A dispute is raised when a transaction that has already reached a terminal state (e.g. Success) is contested — typically by the customer's bank, a chargeback, or an internal investigation. The dispute flow runs on a completely separate track from the normal transaction flow and has its own webhook events.
- When a transaction enters a dispute state, no further normal-flow webhooks are sent for that transaction. The normal webhook channel goes silent from that point forward.
- All dispute state changes are communicated exclusively through dispute-specific events:
payin.dispute_raisedandpayin.dispute_resolved. - Status check is blocked. Returns
response_code: 113for disputed transactions. - Resend callback is blocked. Any request to trigger a callback resend is rejected with
response_code: 113. - Your transaction status should follow the dispute status, not the original terminal status, until resolved.
- Dispute webhooks use the same encryption, same static source IP, and same HTTP 200 acknowledgement as normal webhooks.
Normal Flow (terminal) Dispute Flow (separate track)
│ │
transaction_status │
= "Success" dispute raised
│ │
│ ┌────────────────── iMoney blocks: ──────────────────┐
│ │ • normal webhooks for this pg_ref │
│ │ • status check (returns RC=113) │
│ │ • resend callback (returns RC=113) │
│ └────────────────────────────────────────────────────┘
│ │
│ [dispute_raised webhook]
│ previous_status = "Success"
│ current_status = "Dispute"
│ │
│ [iMoney investigates]
│ │
│ [dispute_resolved webhook]
│ previous_status = "Dispute"
│ current_status = final
│ not_settled_amount, charges,
│ debit, credit
│ │
│ dispute closed
│ (all blocks lifted)
Dispute Raised Webhook #
iMoney fires this webhook the moment a dispute is opened on a previously settled transaction. Update your internal status from its current value to Dispute upon receiving this event.
Additional headers
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
Decrypted payload
{
"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"
}
}Fields
| Field | Type | Description |
|---|---|---|
event | string | Always payin.dispute_raised |
dispute_id | string | Store this for all future dispute correspondence |
dispute_reason | string | Human-readable reason provided by iMoney/bank |
dispute_category | string | CHARGEBACK, FRAUD, DUPLICATE, INVESTIGATION |
previous_status | string | Status before dispute was raised (e.g. Success) |
current_status | string | Always Dispute for this webhook |
amount | string | Original transaction amount |
raised_at | string | Timestamp when dispute was raised (ISO 8601) |
What to do on receipt
- Acknowledge with HTTP 200 immediately.
- Look up the transaction by
pg_refin your system. - Change the transaction status from
previous_status→Dispute. - Store
dispute_id— you will need it when the dispute is resolved. - Do not release or reverse any funds. The dispute is under investigation.
- Do not expect any further normal-flow webhooks for this
pg_ref.
Dispute Resolved Webhook #
iMoney fires this webhook when the dispute investigation concludes. Resolution may favour the merchant or the customer. This webhook carries the financial settlement detail.
Example — Merchant Loss (chargeback upheld)
{
"event": "payin.dispute_resolved",
"data": {
"pg_ref": "PG20260526PAY001A",
"merchant_ref": "ORDER-20260526-001",
"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"
}
}Example — Merchant Win (dispute rejected)
{
"event": "payin.dispute_resolved",
"data": {
"pg_ref": "PG20260526PAY001A",
"merchant_ref": "ORDER-20260526-001",
"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"
}
}Fields
| Field | Type | Description |
|---|---|---|
resolution | string | MERCHANT_WIN or MERCHANT_LOSS |
resolution_remark | string | Human-readable explanation |
not_settled_amount | string | Portion of original amount not settled to your account. MERCHANT_LOSS = full amount; MERCHANT_WIN = "0.00". |
charges | string | Dispute handling/chargeback charges applied to your account |
debit | string | Total debited from your account (not_settled_amount + charges) |
credit | string | Total credited to your account |
resolved_at | string | Resolution timestamp (ISO 8601) |
What to do on receipt
- Acknowledge with HTTP 200 immediately.
- Look up by
dispute_idorpg_ref. - Update transaction status:
Dispute→current_status. - Record
not_settled_amount,charges,debit,creditfor accounting. - If
MERCHANT_LOSS: funds reversed. Reflect in books. - If
MERCHANT_WIN: original settlement stands. No action on customer order.
When to Mark Status During a Dispute #
| Event | Mark As | Notes |
|---|---|---|
payin.dispute_raised | Dispute | Overrides previous terminal status. Do not treat as Success/Failed until resolved. |
resolved + current_status: Success | Success | Merchant win — original status restored. No financial impact. |
resolved + current_status: Failed | Failed | Merchant loss — chargeback upheld. Record debit/charges. |
resolved + current_status: Partial | Partial | Record not_settled_amount separately for reconciliation. |
No resolved after 7 days | Escalate | Contact support with dispute_id. Keep as Dispute. |
| Normal webhook arrives after dispute | Ignore | Discard. Dispute flow is authoritative. |
| Status check on disputed txn | — | Returns 113. Do not retry, do not change status. |
| Resend callback on disputed txn | — | Returns 113. Callback resend blocked. Dispute webhooks are the only active channel. |
Routing dispute events
Dispute webhooks use identical encryption to normal payin webhooks. Same decryption function, same verification steps. Route on the event field after decryption:
DISPUTE_HANDLERS = {
"payin.status_update": handle_normal_webhook,
"payin.dispute_raised": handle_dispute_raised,
"payin.dispute_resolved": handle_dispute_resolved,
}
def payin_webhook_handler(source_ip: str, raw_body: bytes) -> int:
if source_ip not in IMONEY_STATIC_IPS:
return 403
envelope = json.loads(raw_body)
try:
payload = decrypt_webhook(envelope["encrypted"], API_KEY, HASH_KEY)
except Exception:
return 200 # Decryption failed — log and drop
event = payload.get("event")
handler = DISPUTE_HANDLERS.get(event)
if handler:
handler(payload["data"])
return 200const handlers = {
'payin.status_update': handleNormalWebhook,
'payin.dispute_raised': handleDisputeRaised,
'payin.dispute_resolved': handleDisputeResolved,
};
function routeWebhook(sourceIp, rawBody) {
if (!IMONEY_STATIC_IPS.includes(sourceIp)) return 403;
let payload;
try {
const envelope = JSON.parse(rawBody);
payload = decryptWebhook(envelope.encrypted, API_KEY, HASH_KEY);
} catch (e) {
return 200; // Decryption failed — log and drop
}
const handler = handlers[payload.event];
if (handler) handler(payload.data);
return 200;
}Payout Service #
Payout Service documentation will be added here once the spec is finalized. It will cover beneficiary registration, single & bulk disbursement APIs, payout webhook events, and reconciliation flows — using the same encryption model as Payin.
Escalation & Support #
When escalating, always provide:
merchant_ref— your order referencepg_ref— iMoney's reference (if you received it)- Exact timestamp of the transaction
- The full response received from iMoney (or the error you encountered)
| Issue | Contact | Response SLA |
|---|---|---|
| Webhook not received after 30 minutes | tech-support@imoneypg.com | 4 business hours |
| Transaction stuck in Pending > 2 hours | tech-support@imoneypg.com | 2 business hours |
| Route unavailable / no acquirer | ops@imoneypg.com | 2 business hours |
| Encryption / decryption issues | tech-support@imoneypg.com | 4 business hours |
| Balance or reconciliation discrepancy | accounts@imoneypg.com | 1 business day |
| Funds suspected stuck > 4 hours | 🚨 +91-XXXXX-XXXXX | Immediate |
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.