
Shakil AlamMost ICICI Composite API implementations I've seen fail in one of two ways: they get the encryption...
Most ICICI Composite API implementations I've seen fail in one of two ways: they get the encryption layering backwards, or they handle timeouts poorly and end up sending money twice. This guide covers the complete production implementation we use for outbound IMPS transfers — every step explained with the reasoning behind it, not just the code.
Before looking at any code, let's understand why the ICICI API works the way it does.
IMPS transfers carry real money and beneficiary bank details. ICICI protects this in transit using two separate cryptographic operations:
Layer 1 — AES-128-CBC (fast, for the payload).
AES is a symmetric cipher — the same key both encrypts and decrypts. It's very fast and designed for bulk data. Your entire request payload (account number, IFSC, amount, etc.) gets AES-encrypted.
Layer 2 — RSA (slow, for the key).
The problem with AES alone: you need to share the key with ICICI, and if someone intercepts it, they can decrypt every request. RSA solves this. ICICI gives you their public certificate. You encrypt the AES session key with that certificate. Now only ICICI (who holds the matching private key) can recover the AES key and decrypt the payload.
This pattern is called hybrid encryption. RSA for key exchange, AES for data. Every TLS connection uses the same idea.
ICICI's response uses the same scheme in reverse: they encrypt the response data with AES and send you the encrypted session key, which you decrypt using your own private key.
Your App ICICI API
-------- ---------
payload → [AES encrypt] → encryptedData ─────────────────────────────────────────────────── →
sessionKey → [RSA encrypt with ICICI cert] → encryptedKey ─────────────────────────────── →
↓
[RSA decrypt with ICICI's private key]
↓
recovers sessionKey
↓
[AES decrypt] → plaintext payload
↓
process IMPS → encrypted response ← ─ ─ ─ ─ ─
Now the code will make sense.
Get all of these from your ICICI relationship manager before you start:
| Credential | Where it goes |
|---|---|
| API Key |
x-apikey request header |
BC ID (bcID) |
Inside the request payload |
| Passcode | Inside the request payload |
| ICICI's public certificate | storage/app/certificates/icici/ICICI_PUBLIC_CERT.txt |
| Your private key | storage/app/certificates/icici/PRIVATE_KEY.txt |
| Sandbox + production URLs | In config, not hardcoded |
Certificate requirement. The RSA key pair must be 4096-bit. For UAT, a self-signed certificate is acceptable. For production, ICICI requires a CA-signed certificate — self-signed will be rejected. Get your CSR signed by a CA before going live.
IP whitelisting. Your server's public IP must be whitelisted by ICICI before any API call will succeed. If you skip this, every request returns error code 500 ("Not a valid IP for this API call") — which looks like an encryption error at first and is easy to misdiagnose. Whitelist your IPs (including NAT IPs for load-balanced deployments) before you start integration testing.
Add them to .env and config/services.php:
// config/services.php
'icici' => [
'api_key' => env('ICICI_API_KEY'),
'bc_id' => env('ICICI_BC_ID'),
'passcode' => env('ICICI_PASSCODE'),
'sandbox' => env('ICICI_SANDBOX', false),
],
// .env
ICICI_API_KEY=your_api_key_here
ICICI_BC_ID=IBCXXX00000
ICICI_PASSCODE=your_passcode_here
ICICI_SANDBOX=true # flip to false for production
Every IMPS transfer request sends the same set of fields. Here's the full set with explanations of what each field is for:
public function requestParams(float $amount, array $attributes): array
{
// ICICI expects IST timestamps — always set timezone before building this
date_default_timezone_set('Asia/Calcutta');
$localTxnDtTime = date('YmdHis'); // YYYYMMDDHHmmss
// tranRefNo must be unique per transaction attempt
// If this transfer fails and you retry, generate a NEW reference number
// Reusing the same one may get rejected as a duplicate by ICICI
$transactionRefNo = 'TRN_' . uniqid();
// paymentRef appears on the beneficiary's bank statement (max 50 chars)
$paymentReference = substr($attributes['remarks'] ?? 'PAYMENT', 0, 50);
return [
'localTxnDtTime' => $localTxnDtTime, // IST timestamp
'beneAccNo' => $attributes['bank_account_number'],
'beneIFSC' => $attributes['ifsc_code'],
'amount' => $amount, // in rupees, not paise
'tranRefNo' => $transactionRefNo, // your internal reference
'paymentRef' => $paymentReference, // shows on beneficiary statement
'senderName' => substr(config('app.name'), 0, 20), // max 20 characters
'mobile' => config('services.icici.registered_mobile'),
'retailerCode' => config('services.icici.retailer_code', 'rcode'),
'passCode' => config('services.icici.passcode'),
'bcID' => config('services.icici.bc_id'),
'aggrId' => '',
'crpId' => '',
'crpUsr' => '',
];
}
What to log here. Log the full $requestParams (except the passcode — mask it) before sending. If something goes wrong at ICICI's end, these logs are what you send to their support team.
The session key is the AES-128 key that will encrypt your payload. "Session" means it should be unique per request — don't reuse it across calls.
The IV (initialization vector) adds additional randomness to the AES encryption. Even with the same key and same data, a different IV produces different ciphertext.
public function getSessionKey(): string
{
// ICICI accepts session keys of either 16 or 32 characters (128-bit or 256-bit)
// Our codebase uses 16 chars to match AES-128. If you switch to AES-256, use 32.
// Improve this if you're building from scratch:
// return substr(bin2hex(random_bytes(8)), 0, 16);
return '1234567890123456'; // 16 characters = 128 bits
}
public function getIv(): string
{
return '1234567890123456';
}
For new implementations, use random_bytes():
// Stronger version
$sessionKey = substr(bin2hex(random_bytes(8)), 0, 16);
$iv = substr(bin2hex(random_bytes(8)), 0, 16);
Both must be exactly 16 bytes for AES-128. Shorter or longer will cause openssl_encrypt to silently fail or return garbage.
Now encrypt the JSON payload using the session key and IV from Step 2:
public function encryptData(array $data, string $sessionKey, string $iv): string
{
$json = json_encode($data);
// OPENSSL_RAW_DATA means: return raw binary, don't base64-encode it internally
// We'll base64-encode it ourselves in Step 5 for JSON transport
$encrypted = openssl_encrypt(
$json,
'aes-128-cbc',
$sessionKey,
OPENSSL_RAW_DATA,
$iv
);
if ($encrypted === false) {
throw new \RuntimeException('AES encryption failed: ' . openssl_error_string());
}
return $encrypted; // raw binary — not yet safe for JSON
}
The OPENSSL_RAW_DATA flag is important. Without it, PHP would add its own base64 encoding on top of what you're about to do in Step 5, and ICICI would receive double-encoded data it can't parse.
This is the outer envelope. You encrypt the AES session key using ICICI's public certificate:
public function encryptKey(string $sessionKey): string
{
// Load ICICI's public certificate from disk
// This file comes from ICICI during onboarding
$certPath = storage_path('app/certificates/icici/ICICI_PUBLIC_CERT.txt');
$pubKey = file_get_contents($certPath);
if ($pubKey === false) {
throw new \RuntimeException('Could not read ICICI public certificate.');
}
// openssl_public_encrypt stores result in $encryptedKey by reference
$success = openssl_public_encrypt($sessionKey, $encryptedKey, $pubKey);
if (! $success) {
throw new \RuntimeException('RSA encryption failed: ' . openssl_error_string());
}
return $encryptedKey; // raw binary
}
Note that only ICICI can decrypt $encryptedKey — they're the only ones with the matching private key. This is why even if someone intercepts the full request, they cannot recover the AES key and thus cannot decrypt the payload.
Combine everything into the final request body. All binary values must be base64-encoded for JSON transport:
public function payWithIMPS(float $amount, array $attributes): object
{
if ($amount < 1.0) {
throw new \Exception('Minimum IMPS amount is ₹1.00');
}
date_default_timezone_set('Asia/Calcutta');
$params = $this->requestParams($amount, $attributes);
$sessionKey = $this->getSessionKey();
$iv = $this->getIv();
$encData = $this->encryptData($params, $sessionKey, $iv);
$encKey = $this->encryptKey($sessionKey);
$requestBody = json_encode([
'requestId' => 'req_' . time(),
'encryptedKey' => base64_encode($encKey),
'iv' => base64_encode($iv),
'encryptedData' => base64_encode($encData),
'oaepHashingAlgorithm' => 'NONE', // "NONE" = RSA/PKCS1; "SHA1" = OAEP padding
'service' => '', // official doc shows "PaymentApi" — both work in practice
'clientInfo' => '', // optional: pass your server IP for traceability (e.g. '10.0.0.1')
'optionalParam' => '', // reserved for future use by ICICI — always empty
]);
$url = config('services.icici.sandbox')
? 'https://apibankingonesandbox.icicibank.com/api/v1/composite-payment'
: 'https://apibankingone.icicibank.com/api/v1/composite-payment';
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120, // IMPS can take 60–90s during NPCI peak hours
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => $requestBody,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'apikey: ' . config('services.icici.api_key'),
// x-priority is a 4-digit code that selects the payment rail:
// Position 1 (UPI): 1 = UPI priority 1 | Position 2 (IMPS): 1 = IMPS priority 1
// Position 3 (NEFT): 1 = NEFT priority 1 | Position 4 (RTGS): 1 = RTGS priority 1
// '0100' = IMPS only. Use '1000' for UPI-only, '0010' for NEFT-only.
'x-priority: 0100',
],
]);
$response = curl_exec($curl);
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
if ($response === false) {
$error = curl_error($curl);
curl_close($curl);
// Log params (without passcode) so you can trace what was sent
logger()->error('ICICI cURL error', ['error' => $error, 'params' => $this->maskSensitive($params)]);
throw new \Exception('Network error communicating with ICICI: ' . $error);
}
curl_close($curl);
return $this->decryptResponse($response, $httpCode);
}
Why 120-second timeout? IMPS calls during peak NPCI hours can genuinely take 60–90 seconds. A 30-second timeout causes your application to declare failure while the money has actually moved — the worst possible state. A curl timeout doesn't mean the transfer failed; it means you don't know.
ICICI sends back an encrypted response using the same two-layer scheme. They encrypt the response data with an AES key, and encrypt that AES key with your public certificate (they have it from onboarding). You decrypt in reverse:
private function decryptResponse(string $raw, int $httpCode): object
{
$data = json_decode($raw);
if ($data === null) {
logger()->error('ICICI returned non-JSON', ['response' => $raw, 'http_code' => $httpCode]);
throw new \Exception('Invalid response from ICICI.');
}
// Load YOUR private key — used to decrypt the session key ICICI sends back
$privateKeyPath = storage_path('app/certificates/icici/PRIVATE_KEY.txt');
$privateKey = file_get_contents($privateKeyPath);
// Step A: decrypt the session key ICICI encrypted for you
openssl_private_decrypt(
base64_decode($data->encryptedKey),
$recoveredKey,
$privateKey
);
// Step B: use that session key to decrypt the response payload
$decrypted = openssl_decrypt(
base64_decode($data->encryptedData),
'aes-128-cbc',
$recoveredKey,
OPENSSL_RAW_DATA // return raw bytes — don't apply extra base64 internally
);
// The first 16 bytes of the decrypted payload are the IV that ICICI generated
// for this response — not part of the JSON data. Strip them to get the payload.
// Without this, json_decode returns null and you get no useful error.
$payload = substr($decrypted, 16);
$output = json_decode($payload);
if ($output === null) {
logger()->error('ICICI response decryption produced invalid JSON', [
'raw' => $raw,
'decrypted' => $payload,
]);
throw new \Exception('Could not parse ICICI response after decryption.');
}
// ActCode 0 is the only success code
if ($output->ActCode == 0) {
logger()->info('ICICI IMPS success', ['rrn' => $output->BankRRN ?? null, 'response' => $output]);
return $output;
}
$reason = $this->codeToMessage($output->ActCode);
logger()->error('ICICI IMPS failed', ['ActCode' => $output->ActCode, 'reason' => $reason, 'response' => $output]);
throw new \Exception('ICICI payment failed: ' . $reason . ' (ActCode: ' . $output->ActCode . ')');
}
The 16-byte strip. ICICI generates a random IV for each response and prepends it to the encrypted data. When you base64-decode and AES-decrypt encryptedData, the first 16 bytes of the output are that IV — not part of your JSON. substr($decrypted, 16) skips it. Without this, json_decode returns null and you have no idea why.
Map ICICI's numeric error codes to actionable messages:
private function codeToMessage(string|int $code): string
{
return [
'0' => 'Success',
'10' => 'Invalid IFSC / NBIN not registered with ICICI',
'11' => 'NPCI timeout — check Recon360 on T+2 for final status',
'13' => 'Invalid amount',
'14' => 'Invalid card number / beneficiary details',
'16' => 'Format error in request fields',
'25' => 'Transaction not found',
'30' => 'No response from NPCI — beneficiary bank may be offline (check Recon360 on T+2)',
'52' => 'Invalid account number',
'62' => 'Unknown error from NPCI',
'63' => 'Security violation',
'91' => 'Beneficiary bank timeout',
'96' => 'System malfunction',
'403' => 'Wrong parameters or incorrect x-priority header value',
'500' => 'IP not whitelisted — contact ICICI to whitelist your server IP',
'997' => 'Gateway failure — initiate status check after some time',
'8000' => 'Encryption error at client end — check key/certificate setup',
'U78' => 'Beneficiary bank offline',
'RJ' => 'Transaction rejected by beneficiary bank',
][(string) $code] ?? 'Unknown error (code: ' . $code . ')';
}
Reading the error codes. Codes 30, 91, and U78 mean the beneficiary bank or NPCI is temporarily unavailable — retryable after a delay. Codes 10, 52, 14 mean wrong bank details — never auto-retry. Codes 11 and 30 are a special case: NPCI timed out, but the transaction may have settled — check the Recon360 API on T+2 (the next business day after T+2 days) for a definitive answer. Code 997 is ICICI's gateway failing — call the Composite Status API (see next section). Code 500 means your server IP is not whitelisted — fix the whitelist, not your code.
Here's a mistake people make: they skip bank account verification and go straight to the real transfer. When it fails (wrong account number, closed account, IFSC mismatch), they've already debited the user and have to manually reconcile.
The correct approach is a penny drop verification — send ₹1 first, confirm it lands, then do the real transfer.
But there's a critical catch: every penny drop costs ₹1 and most banks don't auto-reverse it. If your verification logic has a bug that lets it run in a loop, you could drain your ICICI account sending ₹1 transfers. This is not theoretical — it happens.
// app/Services/BankVerificationService.php
class BankVerificationService
{
// Maximum verification attempts per bank account per user
// After this, the account must be manually reviewed
private const MAX_ATTEMPTS = 3;
// Minimum gap between attempts (prevents rapid retries)
private const COOLDOWN_HOURS = 6;
public function verify(int $userId, array $bankDetails, ICICIGateway $gateway): bool
{
$accountKey = $bankDetails['bank_account_number'] . '@' . $bankDetails['ifsc_code'];
// Check existing attempts for this user + account combination
$recentAttempts = BankVerificationAttempt::query()
->where('user_id', $userId)
->where('account_hash', md5($accountKey))
->where('attempted_at', '>=', now()->subHours(self::COOLDOWN_HOURS))
->count();
if ($recentAttempts >= self::MAX_ATTEMPTS) {
throw new \Exception(
'Too many bank verification attempts. Wait ' . self::COOLDOWN_HOURS . ' hours or contact support.'
);
}
// Record this attempt BEFORE making the API call
// This prevents a scenario where the process crashes after the transfer
// but before recording the attempt — you'd lose the ₹1 and the counter wouldn't increment
$attempt = BankVerificationAttempt::create([
'user_id' => $userId,
'account_hash' => md5($accountKey), // don't store raw account numbers
'attempted_at' => now(),
'status' => 'pending',
]);
try {
$response = $gateway->payWithIMPS(1.00, array_merge($bankDetails, [
'remarks' => 'VERIFY',
]));
// Mark successful + store the RRN as proof
$attempt->update([
'status' => 'success',
'bank_rrn' => $response->BankRRN ?? null,
'completed_at' => now(),
]);
return true;
} catch (\Exception $e) {
$attempt->update([
'status' => 'failed',
'error' => $e->getMessage(),
'failed_at' => now(),
]);
// Some error codes mean "bank details are wrong" — flag them
if ($this->isInvalidAccountError($e->getMessage())) {
throw new \Exception('Bank account details appear to be invalid. Please double-check the account number and IFSC.');
}
// Other errors (network, NPCI offline) — tell user to try again later
return false;
}
}
private function isInvalidAccountError(string $message): bool
{
return str_contains($message, 'ActCode: 52') // invalid account
|| str_contains($message, 'ActCode: 10') // invalid IFSC
|| str_contains($message, 'ActCode: 14'); // invalid card/account
}
}
The migration for tracking attempts:
Schema::create('bank_verification_attempts', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('user_id');
$table->string('account_hash', 32); // md5 of account+IFSC — not raw number
$table->enum('status', ['pending', 'success', 'failed']);
$table->string('bank_rrn')->nullable();
$table->text('error')->nullable();
$table->timestamp('attempted_at');
$table->timestamp('completed_at')->nullable();
$table->timestamp('failed_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'account_hash', 'attempted_at']);
});
Why record the attempt before making the call? If the process crashes after the transfer but before your code records it, you lose the ₹1 AND the attempt counter doesn't increment. Recording first means the attempt is always counted, even if the API call never completed.
Why account_hash instead of the raw account number? Bank account numbers are PII. Hashing prevents raw account numbers from sitting in your database if it's ever compromised, while still letting you count attempts per account.
The article has told you twice: "don't retry a timed-out IMPS call." But it hasn't told you how to actually find out what happened. That's the Composite Status API.
When your curl times out, or you get error code 997 (gateway failure), or the response never arrives — this is the endpoint you call to check whether the transfer settled:
Endpoint: POST https://apibankingone.icicibank.com/api/v1/composite-status
Header: x-priority: 0100 (same as the payment endpoint)
The request payload uses the same encryption scheme as the payment call, but the inner JSON is simpler:
// app/Services/ICICIGateway.php
public function checkTransactionStatus(string $tranRefNo, string $date): object
{
$params = [
'transRefNo' => $tranRefNo, // the tranRefNo you sent in the original payment
'passCode' => config('services.icici.passcode'),
'bcID' => config('services.icici.bc_id'),
'date' => $date, // MM/DD/YYYY format — date the payment was made
'recon360' => 'N', // set to 'Y' only when checking codes 11 or 30 on T+2
];
$sessionKey = $this->getSessionKey();
$iv = $this->getIv();
$encData = $this->encryptData($params, $sessionKey, $iv);
$encKey = $this->encryptKey($sessionKey);
$requestBody = json_encode([
'requestId' => 'status_' . time(),
'encryptedKey' => base64_encode($encKey),
'iv' => base64_encode($iv),
'encryptedData' => base64_encode($encData),
'oaepHashingAlgorithm' => 'NONE',
'service' => 'PaymentApi',
'clientInfo' => '',
'optionalParam' => '',
]);
$url = config('services.icici.sandbox')
? 'https://apibankingonesandbox.icicibank.com/api/v1/composite-status'
: 'https://apibankingone.icicibank.com/api/v1/composite-status';
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => $requestBody,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'apikey: ' . config('services.icici.api_key'),
'x-priority: 0100',
],
]);
$response = curl_exec($curl);
curl_close($curl);
return $this->decryptResponse($response, 200);
}
Three fields in the outer request wrapper explained here:
clientInfo — "ClientIP or other information." Optional, not stored by ICICI. You can pass your server's IP address (e.g. '10.0.1.5') to make request tracing easier when you contact ICICI support with a problem. Leaving it empty is fine.optionalParam — "Reserved for future use." ICICI has no use for this currently. Always "".service — The official ICICI documentation sample shows "PaymentApi". Empty string also works in practice. Follow whatever ICICI configures for your account.The full status check implementation:
public function checkTransactionStatus(string $tranRefNo, string $date): object
{
$params = [
'transRefNo' => $tranRefNo,
'passCode' => config('services.icici.passcode'),
'bcID' => config('services.icici.bc_id'),
'date' => $date, // MM/DD/YYYY — the date the original payment was made
'recon360' => 'N', // 'N' = standard status; 'Y' = Recon360 (for codes 11 & 30 on T+2)
];
$sessionKey = $this->getSessionKey();
$iv = $this->getIv();
$encData = $this->encryptData($params, $sessionKey, $iv);
$encKey = $this->encryptKey($sessionKey);
$requestBody = json_encode([
'requestId' => 'status_' . time(),
'encryptedKey' => base64_encode($encKey),
'iv' => base64_encode($iv),
'encryptedData' => base64_encode($encData),
'oaepHashingAlgorithm' => 'NONE',
'service' => '',
'clientInfo' => '', // can pass server IP for debug traceability
'optionalParam' => '', // reserved, always empty
]);
$url = config('services.icici.sandbox')
? 'https://apibankingonesandbox.icicibank.com/api/v1/composite-status'
: 'https://apibankingone.icicibank.com/api/v1/composite-status';
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => $requestBody,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'apikey: ' . config('services.icici.api_key'),
'x-priority: 0100', // same as payment endpoint — selects IMPS status
],
]);
$response = curl_exec($curl);
curl_close($curl);
return $this->decryptResponse($response, 200);
}
What the status response contains. When ActCode == 0, you get much more than just BankRRN:
{
"ActCode": "0",
"Response": "Transaction Successful",
"BankRRN": "922019796797",
"BeneName": "PARTNER NAME",
"TranRefNo": "TRN_abc123",
"PaymentRef": "CommPay42",
"TranDateTime": "29-07-2024 11:37:13",
"Amount": "9800",
"BeneAccNo": "001700000004",
"BeneIFSC": "HDFC0922915",
"RemMobile": "9622233007",
"RemName": "Your Company Name",
"RetailerCode": "rcode"
}
Log all of these when a status check confirms success — especially TranDateTime and BeneAccNo — because they're your proof that the right account received money at the right time.
Using the response in your approval flow:
try {
$response = $gateway->payWithIMPS($amount, $attributes);
// success — record normally
} catch (\Exception $e) {
$tranRefNo = $attributes['tranRefNo']; // must be saved before the call
$date = now()->format('m/d/Y');
logger()->warning('IMPS call failed — running status check', [
'tranRefNo' => $tranRefNo,
'error' => $e->getMessage(),
]);
sleep(5); // brief pause before status check
try {
$status = $gateway->checkTransactionStatus($tranRefNo, $date);
if ($status->ActCode == 0) {
logger()->info('Status check: transfer confirmed', [
'rrn' => $status->BankRRN ?? null,
'tran_ref' => $status->TranRefNo ?? null,
'amount' => $status->Amount ?? null,
'bene_account' => $status->BeneAccNo ?? null,
'tran_time' => $status->TranDateTime ?? null,
]);
$this->recordTransaction($status->BankRRN ?? null);
$this->doApprove();
return;
}
// Non-zero ActCode from status API — transfer did not go through
logger()->info('Status check: transfer not found or failed', [
'ActCode' => $status->ActCode,
]);
} catch (\Exception $statusError) {
logger()->error('Status check failed too', ['error' => $statusError->getMessage()]);
}
throw $e; // re-throw — unlock happens in the catch block above
}
When does IMPS actually settle? The official answer: between 180 seconds and T+2 days. Most transactions settle in under 3 minutes. NPCI failures can push the final status to days later.
The recon360: 'Y' flag. For error codes 11 (NPCI timeout) or 30 (no NPCI response), call the status check on T+2 with recon360: 'Y'. This routes to ICICI's Recon360 reconciliation system, which has the definitive answer for these edge cases.
The Recon360 response has a different nested structure from the standard status response:
// When recon360 = 'Y', parse the response differently
public function parseRecon360Response(object $response): array
{
// Recon360 wraps parameters as an array of name/value pairs
$params = $response->{'recon-api-response'}->parameters->index->parameter ?? [];
$data = [];
foreach ($params as $param) {
$data[$param->name] = $param->value ?? null;
}
// Key fields to look for:
// $data['CurrentTransactionStatus'] → "Credit Confirmation Received" = success
// → "Failed", "Returned received" = failed
// → "Deemed approved", "Suspect", "Timeout" = still pending
// $data['RRN'] → Bank RRN
// $data['TransactionAmount'] → Amount
// $data['BeneficiaryAccountNumber'] → Beneficiary account (masked)
return $data;
}
The key field to check in Recon360 is CurrentTransactionStatus:
"Credit Confirmation Received", "Success", "Completed" → transfer settled"Failed", "Returned received" → transfer failed, safe to retry with a new tranRefNo
"Deemed approved", "Suspect", "Timeout" → still unresolved — wait and check againThe most dangerous scenario: your server sends the IMPS request, ICICI processes it (money moves), but your curl times out before the response arrives. Your code throws an exception. If you then retry, you pay the same person twice.
// In WithdrawalRequest model
public function payWithIMPS(): void
{
// Pessimistic lock — sets locked_at = now()
$this->lock();
try {
$gateway = new ICICIGateway();
$response = $gateway->payWithIMPS($this->net_amount, $this->bankAttributes());
// Money has moved — record everything before touching the lock
$this->recordTransaction($response->BankRRN ?? null);
$this->doApprove();
} catch (\Exception $e) {
// Network timeout or business error
// Don't unlock — leave the request locked so a human reviews it
// before any retry is attempted
logger()->critical('ICICI transfer failed', [
'withdrawal_id' => $this->id,
'error' => $e->getMessage(),
'locked_at' => $this->locked_at,
]);
throw $e;
}
$this->unlock();
}
When a transfer is in the "timed out but maybe completed" state, call the Composite Status API (see the section above) with the same tranRefNo. If that also fails, then call ICICI support with the tranRefNo — they can look it up on their end.
Never auto-retry a timed-out IMPS call without confirming the first one failed.
The BankRRN (Retrieval Reference Number) is the bank network's unique ID for the transfer. Store it in your TransactionLog:
public function getRRN(object $response): string
{
return $response->BankRRN ?? '';
}
When a partner calls saying "I didn't receive the money," the RRN is what you give them to trace with their bank. NPCI can track any IMPS transfer using the RRN.
Timezone not set. date_default_timezone_set('Asia/Calcutta') must be called before generating localTxnDtTime. If you're in UTC and don't set it, ICICI gets a timestamp that's 5.5 hours off and may reject the request.
tranRefNo reused on retry. Always uniqid() a fresh reference on each attempt. ICICI may reject duplicate reference numbers.
Timeout too short. Use 120 seconds minimum. 30 seconds is not enough during NPCI peak load.
Skipped the 16-byte strip. ICICI includes the response IV as the first 16 bytes of the decrypted output. $payload = substr($decrypted, 16) removes it. Without this, json_decode returns null and you get no useful error.
Relative certificate paths. Always use storage_path(). Queue workers and CLI commands run from a different working directory and relative paths will break.
Treating any non-zero ActCode as retryable. Codes 10, 52 (bad account details) should never be auto-retried. Only network/timeout codes (30, 91, U78) are safe to retry after a delay.
openssl_encrypt docsopenssl_public_encrypt docsFAQ
Q: What's the minimum transfer amount?
₹1.00. Useful for penny drop bank verification. The API rejects anything below this.Q: How long does IMPS take?
Most transactions settle within 3 minutes, but the official answer is: between 180 seconds and T+2 business days. NPCI failures can push final status to days later. Always use a 120-second curl timeout and use the Composite Status API to check rather than assuming success or failure.Q: What if the curl times out but the money moved?
Call the Composite Status API with the sametranRefNo— it tells you whether the transfer settled. If that also fails, call ICICI support. Log thetranRefNobefore every payment call for exactly this scenario.Q: Can I do bulk payouts?
This endpoint is one transfer per call. For bulk, use Laravel queued jobs with a rate limiter — ICICI has per-minute limits that vary by merchant tier.Q: How many bank verification attempts should I allow?
We use 3 per account per 6 hours. Set your own policy based on your risk model, but always have a hard limit. The ₹1 per attempt adds up, and an infinite loop has real cost.
Shakil · blog.shakiltech.com