iMoney PA-O Merchant Integration Docs
v1.0 · May 2026
No matches. Try a different keyword.
Payment Aggregator — Online

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.

Version · 1.0
Effective · May 2026
Audience · Developers integrating iMoney
Support · tech-support@imoneypg.com

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

EnvironmentBase URLPurpose
UAThttps://uatpayin.imoneypg.comSandbox — use during integration & testing
Productionhttps://payin.imoneypg.comLive — for real customer transactions

Three rules that govern everything

⚠️ Read these before integrating
  1. Every API response returns HTTP 200. Never use HTTP status code to determine transaction outcome. Always read response_code inside the JSON body.
  2. Every request body must be AES-256-GCM encrypted. Plaintext requests are rejected.
  3. 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 #

ItemDescriptionProvided By
api_keyYour merchant identifier, sent in every request headeriMoney Onboarding
hash_keyYour encryption secret — used to derive the AES key and signature. Never expose this.iMoney Onboarding
IP WhitelistYour server outbound IP(s) registered with iMoney — requests from unregistered IPs are rejectedMerchant provides, iMoney configures
Webhook URLYour HTTPS endpoint to receive payment status updatesMerchant provides, iMoney configures
Route Name(s)Assigned to your account — required only for Prepaid merchantsiMoney Onboarding
Merchant TypePrepaid or Non-prepaid — determines which init endpoint to useiMoney Onboarding
iMoney Webhook IPThe static IP from which iMoney sends all webhook callbacks — whitelist this on your serveriMoney Onboarding
🔒 Security Notice

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.

TypeDescriptionInit Endpoint
PrepaidYou supply a route_name from your assigned MerchantAcquirerAccount. iMoney routes through that specific acquirer.POST /api/v3/payin/init/
Non-PrepaidiMoney 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

HeaderRequired OnFormatExample
API-KEYAll endpointsPlain string9b80b436a5ff
X-TimestampAll endpointsUnix seconds as string1716374400
X-SignatureInit endpoints onlyLowercase hexa3f8c2…
X-Merchant-RefInit endpointsYour order/merchant referenceORDER-20260526-001
X-Customer-IPInit endpointsEnd customer's real IP address203.0.113.45
Content-TypePOST endpointsFixedapplication/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

ComponentValue
timestampThe exact same value you send in X-Timestamp
merchant_refYour unique order reference, exactly as it appears in the payload
amountAmount as a string exactly as in payload (e.g. "1000.00")
body_bytesThe 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) #

POST/api/v3/payin/init/

Headers: API-KEY, X-Timestamp, X-Signature, X-Merchant-Ref, X-Customer-IP, Content-Type: application/json

Request Fields

FieldTypeRequiredValidation
uidstringYesYour internal customer identifier
merchant_refstringYesUnique per merchant, forever. Cannot be reused.
amountstringYesPositive decimal, max 2 decimal places (e.g. "1000.00")
currencystringYes"INR"
payin_typestringYesUPI, INTENT, QR, NETBANKING — as supported on your route
route_namestringYesYour assigned route name — from iMoney onboarding
customer_namestringYesEnd customer's full name
customer_mobilestringYesEnd customer's mobile number
customer_emailstringNoEnd customer's email address
customer_vpastringNoEnd customer's UPI VPA (sent to iMoney FUSE for fraud screening)
descriptionstringNoOrder 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) #

POST/api/v3/payin/bank/init/

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 #

POST/api/v3/payin/

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

FieldTypeRequiredRule
transaction_idstringYesFrom Step 1 response
amountstringYesMust exactly match the amount from Step 1
payin_typestringYesMust 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 #

GET/api/v3/payin/status/{pg_ref}/
GET/api/v3/payin/status/merchant/{merchant_ref}/

Headers: API-KEY, X-Timestamp

⏱️ Do not call before 5 minutes

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.

⚖️ Disputed transactions

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.

CodeMeaningRetryableAction Required
000SuccessNoTerminal success — payment received and confirmed
001PendingNo — await webhookNormal state after execute. Customer payment in progress. Await webhook.
002InitiatedNo — await webhookPayment flow started by customer. Intermediate state. Await webhook.
003FailedYes — new merchant_refPayment failed. Safe to retry with a new merchant_ref.
101Internal server errorOnce, after 60sRetry once after 60 seconds. If it persists, contact support.
102Duplicate merchant_refNoThis reference was already used. Generate a fresh unique merchant_ref.
103Rate limit exceededYes, reduce frequencySlow down request rate per IP.
104Invalid or missing fieldYes after correctionRead message for which field to fix. Retry from Step 1.
105Route unavailableNoNo acquirer available for the given amount/type. Contact iMoney Ops.
106Invalid amount / mismatchYes after correctionAmount format wrong or doesn't match init. Retry from Step 1.
107Currency not supportedNoContact iMoney to enable the required currency on your account.
108Timestamp expired/missingYesSync server clock via NTP. X-Timestamp must be within ±5 minutes.
109Status checked too earlyYes after retry_after_secondsWait the exact number of seconds given in retry_after_seconds, then retry.
110Decryption failedYes after fixVerify hash_key is correct and your encryption implementation is accurate.
111Transaction ID invalid/expiredYes — re-initiateThe transaction_id is expired or already used. Call Step 1 again.
112Blocked by fraud engineNoContact iMoney support. Do not retry automatically.
113Transaction under disputeNoStatus check and resend callback disabled. Await payin.dispute_resolved webhook.
401Authentication errorNoAPI-KEY missing/not recognised, or server IP not whitelisted. Read message.
404Transaction not foundNoVerify that pg_ref or merchant_ref is correct.

Transaction Status Reference #

StatusMeaningTerminal?Your Action
PendingPayment awaiting customer actionNoKeep as Pending. Await webhook. Do not mark final.
InitiatedCustomer has started the payment flowNoKeep as Pending. Await webhook. Do not mark final.
SuccessPayment received and confirmed by bankYesMark as Success. Release the order or credit the customer.
FailedPayment failed or rejectedYesMark as Failed. Allow customer to retry with a new order reference.
ExpiredCustomer did not pay in timeYesMark 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.

ScenarioMark AsNotes
Execute returns 001PendingStandard post-execute state. Do not mark final. Await webhook.
Execute returns 102FailedDuplicate merchant_ref — definitive. No payment was initiated.
Execute returns 104FailedInvalid field — definitive. No payment was initiated. Fix and retry from Step 1.
Execute returns 111FailedTransaction ID expired before execute. Call init again with a fresh merchant_ref.
Execute returns 110FailedDecryption error — request was rejected. Fix encryption.
Execute returns any other error codePendingUncertain state. Do not mark Failed. Contact support with merchant_ref.
HTTP 4xx from gatewayPendingRequest may not have reached iMoney. Check status after 5 minutes.
HTTP 5xx from gatewayPendingInfrastructure error. Do not retry immediately.
Connection timeout during Step 2PendingNetwork failure — unknown whether request was received. Check status after 5 min.
Webhook: transaction_status: SuccessSuccessTerminal. Release order or credit customer. Authoritative signal.
Webhook: transaction_status: FailedFailedTerminal. Allow customer to retry with a new order.
Webhook: transaction_status: ExpiredFailed/ExpiredTerminal. Customer did not pay in time. Allow retry.
Status API returns 000SuccessConfirms the webhook signal. Use as reconciliation only.
Status API returns 003FailedConfirms failure. Use as reconciliation only.
No webhook after 24 hoursEscalateContact 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.

⚠️ Why this matters

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.

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

FieldTypeDescription
pg_refstringiMoney's unique transaction reference
merchant_refstringYour order reference from the init request
transaction_statusstringFinal status: Success, Failed, or Expired
amountstringThe amount from the init request
captured_amountstringActual amount received — may differ from amount. Use this for reconciliation.
payin_typestringPayment method used by the customer
customer_utrstringUTR/reference from the customer's bank
bank_refstringiMoney's bank-side reference
currencystringCurrency code
created_atstringTransaction creation time (ISO 8601)
updated_atstringLast 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 immediately
app.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:

1

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.

2

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.

Key rules that set disputes apart
  • 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_raised and payin.dispute_resolved.
  • Status check is blocked. Returns response_code: 113 for 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

FieldTypeDescription
eventstringAlways payin.dispute_raised
dispute_idstringStore this for all future dispute correspondence
dispute_reasonstringHuman-readable reason provided by iMoney/bank
dispute_categorystringCHARGEBACK, FRAUD, DUPLICATE, INVESTIGATION
previous_statusstringStatus before dispute was raised (e.g. Success)
current_statusstringAlways Dispute for this webhook
amountstringOriginal transaction amount
raised_atstringTimestamp when dispute was raised (ISO 8601)

What to do on receipt

  1. Acknowledge with HTTP 200 immediately.
  2. Look up the transaction by pg_ref in your system.
  3. Change the transaction status from previous_statusDispute.
  4. Store dispute_id — you will need it when the dispute is resolved.
  5. Do not release or reverse any funds. The dispute is under investigation.
  6. 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

FieldTypeDescription
resolutionstringMERCHANT_WIN or MERCHANT_LOSS
resolution_remarkstringHuman-readable explanation
not_settled_amountstringPortion of original amount not settled to your account. MERCHANT_LOSS = full amount; MERCHANT_WIN = "0.00".
chargesstringDispute handling/chargeback charges applied to your account
debitstringTotal debited from your account (not_settled_amount + charges)
creditstringTotal credited to your account
resolved_atstringResolution timestamp (ISO 8601)

What to do on receipt

  1. Acknowledge with HTTP 200 immediately.
  2. Look up by dispute_id or pg_ref.
  3. Update transaction status: Disputecurrent_status.
  4. Record not_settled_amount, charges, debit, credit for accounting.
  5. If MERCHANT_LOSS: funds reversed. Reflect in books.
  6. If MERCHANT_WIN: original settlement stands. No action on customer order.

When to Mark Status During a Dispute #

EventMark AsNotes
payin.dispute_raisedDisputeOverrides previous terminal status. Do not treat as Success/Failed until resolved.
resolved + current_status: SuccessSuccessMerchant win — original status restored. No financial impact.
resolved + current_status: FailedFailedMerchant loss — chargeback upheld. Record debit/charges.
resolved + current_status: PartialPartialRecord not_settled_amount separately for reconciliation.
No resolved after 7 daysEscalateContact support with dispute_id. Keep as Dispute.
Normal webhook arrives after disputeIgnoreDiscard. Dispute flow is authoritative.
Status check on disputed txnReturns 113. Do not retry, do not change status.
Resend callback on disputed txnReturns 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 200
const 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 #

🚧 Coming soon

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:

  1. merchant_ref — your order reference
  2. pg_ref — iMoney's reference (if you received it)
  3. Exact timestamp of the transaction
  4. The full response received from iMoney (or the error you encountered)
IssueContactResponse SLA
Webhook not received after 30 minutestech-support@imoneypg.com4 business hours
Transaction stuck in Pending > 2 hourstech-support@imoneypg.com2 business hours
Route unavailable / no acquirerops@imoneypg.com2 business hours
Encryption / decryption issuestech-support@imoneypg.com4 business hours
Balance or reconciliation discrepancyaccounts@imoneypg.com1 business day
Funds suspected stuck > 4 hours🚨 +91-XXXXX-XXXXXImmediate

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.