Payin P2P Seamless Wallet Integration
For merchant on-boarding in both UAT and production environments, the merchant needs to provide the following information:
IP address (For dynamic IPs, please provide a range of IP addresses).
Merchant callback URL for posting back the final transaction status from our end.
Once the merchant provides this technical list, we will perform the necessary configuration on our end and provide the Merchant ID (MID/PID).
(Note: In the documentation, the terms "postBack," "Callback," and "Webhook" refer to the same process.)
Let's see how it works:
The merchant will send payment collection requests through our API. Along with this, the merchant must provide the order ID, PID, amount, wallet type, name, email, and phone details.
PAYMENT REQUEST: Upon receiving requests in the correct format, we will share wallet_id which is needed in customer screen for payment.
UTR Submit: The merchant should collect the "Transaction ID" or "Bank Reference" from the customer and submit it to us by invoking the
collection_utrAPI endpoint.Callback: Once the customer makes the payment, the callback data will be sent to the provided callback URL.
Status Polling: You can confirm or check the payment status at any time by calling the
polling_apiand updating your system accordingly.
PAYMENT REQUEST :
Before proceeding, ensure you have reviewed the basic workflow. This section explains how to send the payment request.
Note: All requests must come from whitelisted IPs. Please confirm that your IP is whitelisted.
Payment Request
POST https://<domain>/api/request.php
Merchant makes a payment request.
Headers
Content-Type
application/json
Body
pid
string
provided MID/PID
Yes
order_id
string
unique order id
Yes
amount
int
requested amount
Yes
wallet_type
string
Wallet type, allowed values are ('Nagad','Rocket','bKash')
Yes
name
string
Customer's name
Yes
email
string
Customer's email
Yes
phone
string
Customer's phone number
Yes
Sample Request Body
{
"pid": "0951272386617",
"order_id": "TXe3993N292jdwd8jjjidfje993",
"amount": 43,
"wallet_type": "bKash",
"name":"john",
"email":"[email protected]",
"phone":"738296352"
}
Note: wallet_type is mandatory and important for Bangladesh to work properly.
Sample Response Body
{
"ref_code": "f0969157092fc013c69ade8f4feff483f886e1e0ef7021b22d390d66260884a8",
"wallet_id": "01774725445",
"wallet_type": "bKash",
"amount": 43,
"status": "success",
"wallet_url": ""
}TRANSACTION REFERENCE/UTR SUBMIT :
The merchant needs to design a page where the customer can submit the "Bank Reference" or "Transaction ID" for the payment.
Capture Transaction Reference
POST https://<domain>/api/collection_utr.php
Merchant has to capture a transaction reference from the customer and submit it to us to verify the payment for transaction approval.
Headers
Content-Type
application/json
Body
ref_code
string
given ref_code of transaction
Yes
pid
string
merchant PID/MID
Yes
utr
string
"Bank Reference"/"Transaction Id"
Yes
amount
int
amount in integer
Yes
Sample Request Body
{
"ref_code":"8d94cbdb89aeda7b900a73be951aafdd645b1e16fcf5d9fc1a09ddc6e6d19380",
"pid":"232323232",
"utr":"gfgfh434",
"amount": 12
}Response
{
"success": "UTR Saved for the Transaction"
}{
"error": "Error message"
}CALLBACK
We invoke your callback URL with callback data whenever there is a status change against the transaction.
Valid Transaction status are:
Approved
Declined
Late Approved
Pending
User Timed Out
Cancelled
Failed
Amount Mismatch
The most famous transaction changes are (but not limited):
Pending=>Approved
Pending=>Declined
Pending=>User Timed Out
User Timed Out=>Late Approved
The callback landing page must be set up on your server at a secret path, but it should be publicly accessible from our whitelisted IP. (Ensure that it is only accessible from our server IP.)
In the POST body, the following properties will be provided in JSON format:
order_id
string
Your order id shared
requested_amount
string
requested amount
received_amount
string
received amount
bank_ref
string
transaction reference/bank reference/UTR if available
ref_code
string
unique code for the transaction
status
string
status of payment at this time
post_hash
string
signature post hash for security verification
Note:
please consider received_amount for final transaction processing
Follow the steps to verify the integrity of received data:
Capture and Decode the Payload
Capture the raw JSON data from the POST body and decode it.
$data = file_get_contents("php://input");
$array = json_decode($data, true);Extract and Decode the
post_hash
The post_hash field in the JSON payload is a Base64-encoded string. You must Base64-decode it to get the raw binary data needed for decryption.
// Get the Base64 string from the array
$post_hash_base64 = $array['post_hash'];
// Decode it to get the raw binary ciphertext
$ivHashCiphertext = base64_decode($post_hash_base64);Decrypt the Binary Data to get the Remote Hash
Pass the raw binary data (not the Base64 string) to the decrypt function along with your secret key.
//$secret_key is your provided SECRET KEY
$remote_hash = decrypt($ivHashCiphertext, $secret_key);The decrypt function for your language is provided in the reference section below.
function decrypt($ivHashCiphertext, $password)
{
$method = "AES-256-CBC";
$iv = substr($ivHashCiphertext, 0, 16);
$hash = substr($ivHashCiphertext, 16, 32);
$ciphertext = substr($ivHashCiphertext, 48);
$key = hash('sha256', $password, true);
if (!hash_equals(hash_hmac('sha256', $ciphertext . $iv, $key, true),$hash))
return null;
return openssl_decrypt($ciphertext, $method, $key,
OPENSSL_RAW_DATA, $iv);
}const crypto = require('crypto');
/**
* @param {Buffer} ivHashCiphertext - The raw binary data (Buffer) after Base64-decoding.
* @param {string} password - Your secret key.
* @returns {string|null} The decrypted plaintext or null on failure.
*/
function decrypt(ivHashCiphertext, password) {
const method = 'aes-256-cbc';
// Extract the initialization vector (first 16 bytes)
const iv = ivHashCiphertext.slice(0, 16);
// Extract the hash (next 32 bytes)
const hash = ivHashCiphertext.slice(16, 48);
// Extract the ciphertext (remaining bytes)
const ciphertext = ivHashCiphertext.slice(48);
// Generate the key using SHA-256 hash of the password
const key = crypto.createHash('sha256').update(password, 'utf8').digest();
// Compute HMAC-SHA256 of (ciphertext || iv) using the key
const hmac = crypto.createHmac('sha256', key)
.update(Buffer.concat([ciphertext, iv]))
.digest();
// Compare the computed HMAC with the extracted hash
if (!crypto.timingSafeEqual(hmac, hash)) {
return null; // Hashes do not match; return null
}
try {
// Decrypt the ciphertext using AES-256-CBC
const decipher = crypto.createDecipheriv(method, key, iv);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]);
return plaintext.toString('utf8'); // Return the decrypted text as a UTF-8 string
} catch (err) {
// Decryption failed; return null
return null;
}
}import hashlib
import hmac
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
def decrypt(ivHashCiphertext, password):
"""
Decrypts the iv/hash/ciphertext blob.
:param ivHashCiphertext: The raw binary data (after base64-decoding).
:param password: Your secret key string.
:return: The decrypted plaintext string, or None on failure.
"""
try:
# 1. Extract the components
iv = ivHashCiphertext[:16]
hash_val = ivHashCiphertext[16:48]
ciphertext = ivHashCiphertext[48:]
# 2. Generate the key
key = hashlib.sha256(password.encode('utf-8')).digest()
# 3. Verify the HMAC
computed_hash = hmac.new(key, ciphertext + iv, hashlib.sha256).digest()
if not hmac.compare_digest(computed_hash, hash_val):
return None
# 4. Decrypt the ciphertext
backend = default_backend()
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=backend)
decryptor = cipher.decryptor()
# Decrypt and unpad
# Using PKCS7 unpadder explicitly is more robust
decrypted_padded_text = decryptor.update(ciphertext) + decryptor.finalize()
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
decrypted_text = unpadder.update(decrypted_padded_text) + unpadder.finalize()
return decrypted_text.decode('utf-8')
except Exception as e:
# Catch any error (e.g., bad padding, bad key) and return None
# print(f"Decryption failed: {e}") # Optional: for debugging
return NoneCompute the Local Hash
Compute the local hash using the MD5 128-bit hashing algorithm. Use the order_id, received_amount, and status received in the callback array for computing the local hash.
// Get the values from the same callback $array
$order_id = $array['order_id'];
$received_amount = $array['received_amount'];
$status = $array['status'];
$local_hash = md5($order_id . $received_amount . $status . $secret_key);Verify Hash
Compare the decrypted $remote_hash from the request and the computed $local_hash.
if ($remote_hash === $local_hash)
{
// consider received amount to update
// Mark the transaction as success & process the order
// You can write code process the order here
// Update your db with payment success
$hash_status = "Hash Matched";
}
else
{
// Verification failed
$hash_status = "Hash Mismatch";
}Acknowledge the Payment Gateway
To confirm you have received the callback and to prevent our gateway from sending retries, you must do two things:
Respond with an HTTP 200 OK status code.
Respond with a JSON body containing the key
"acknowledge"set to the string"yes".
Our system will check for both the 200 status and the {"acknowledge": "yes"} in the response body. If either is missing, we will assume the callback failed and will attempt to send it again.
// --- This is the required acknowledgment ---
// 1. Set the HTTP 200 OK status code
http_response_code(200);
// 2. Prepare the required JSON response body
$response_data = [
'acknowledge' => 'yes',
'hash_status' => $hash_status // You can include this for your own logs
];
// 3. Send the response
header('Content-Type: application/json; charset=utf-8');
echo json_encode($response_data);
exit;STATUS POLLING :
POST https://<domain>/api/status_polling.php
This API is used to poll the status of a particular transaction.
Headers
Content-Type
application/json
Body
pid
string
Merchant ID/PID
Yes
ref_code
string
unique ref_code which is generated in payment request
Yes
post_hash
string
The Base64-encoded encrypted hash. (See steps below).
Yes
Steps to generate post_hash :
Generate the Request
post_hash
1.1 Create Plaintext Hash: Concatenate the ref_code, pid, and your secret_key, then create an MD5 hash.
$ref_code = "YOUR_REF_CODE";
$pid = "YOUR_PID";
$secret_key = "YOUR_SECRET_KEY";
$local_hash = md5($ref_code . $pid . $secret_key);const crypto = require('crypto');
const ref_code = "YOUR_REF_CODE";
const pid = "YOUR_PID";
const secret_key = "YOUR_SECRET_KEY";
const local_hash = crypto
.createHash('md5')
.update(ref_code + pid + secret_key)
.digest('hex');
import hashlib
ref_code = "YOUR_REF_CODE"
pid = "YOUR_PID"
secret_key = "YOUR_SECRET_KEY"
local_hash = hashlib.md5((ref_code + pid + secret_key).encode('utf-8')).hexdigest()1.2 Encrypt the Hash: Encrypt the $local_hash using the encrypt function shown below.
$encrypted_hash = encrypt($local_hash, $secret_key);function encrypt($plaintext, $password)
{
$method = "AES-256-CBC";
$key = hash('sha256', $password, true);
$iv = openssl_random_pseudo_bytes(16);
$ciphertext = openssl_encrypt($plaintext, $method, $key,
OPENSSL_RAW_DATA, $iv);
$hash = hash_hmac('sha256', $ciphertext . $iv, $key, true);
return $iv . $hash . $ciphertext;
}import hashlib
import hmac
import os
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding as crypto_padding
def encrypt(plaintext, password):
key = hashlib.sha256(password.encode('utf-8')).digest()
iv = os.urandom(16)
cipher = AES.new(key, AES.MODE_CBC, iv)
plaintext_bytes = plaintext.encode('utf-8')
padded_plaintext = pad(plaintext_bytes, AES.block_size)
ciphertext = cipher.encrypt(padded_plaintext)
hash_value = hmac.new(key, ciphertext + iv, hashlib.sha256).digest()
return iv + hash_value + ciphertextconst crypto = require('crypto');
function encrypt(plaintext, password) {
const method = 'aes-256-cbc';
const key = crypto.createHash('sha256').update(password, 'utf8').digest();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(method, key, iv);
let ciphertext = cipher.update(plaintext, 'utf8');
ciphertext = Buffer.concat([ciphertext, cipher.final()]);
const hmac = crypto.createHmac('sha256', key);
hmac.update(Buffer.concat([ciphertext, iv]));
const hash = hmac.digest();
// Return the raw binary Buffer
return Buffer.concat([iv, hash, ciphertext]);
}1.3 Base64 Encode: Base64-encode the raw binary output from the encrypt function. This final string is your post_hash.
$post_hash = base64_encode($encrypted_hash);Send the POST Request
Send a POST request containing pid, ref_code, and post_hash as a JSON body , and you will receive a response after validating the data.
<?php
// Your transaction data
$pid = "YOUR_PID";
$ref_code = "YOUR_REF_CODE";
$secret_key = "YOUR_SECRET_KEY";
// --- Step 1: Generate Hash ---
$local_hash = md5($ref_code . $pid . $secret_key);
$encrypted_hash = encrypt($local_hash, $secret_key);
$post_hash = base64_encode($encrypted_hash);
// --- Step 2: Send Request ---
$api_url = "https://<domain>/api/status_polling.php";
$data = [
'pid' => $pid,
'ref_code' => $ref_code,
'post_hash' => $post_hash
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $api_url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$server_output = curl_exec($ch);
curl_close($ch);
?>
Processing the API Response
The API will respond with a JSON object. If the request is successful and the ref_code is found, it will return the transaction details. If it fails (e.g., bad hash, ref_code not found), it will return an error message.
Success Response Parameters
A successful response will contain the following parameters as JSON body:
order_id
String
Your order ID.
ref_code
String
The unique ref_code for this transaction.
upi_id
String
The UPI ID the payment was made to.
requested_amount
Number
The amount originally requested for the transaction.
received_amount
Number
The final amount confirmed as received.
bank_ref
String
The bank's UTR / reference number, if available.
sender_upi
String
The customer's UPI ID from which the payment was received.
webhook_acknowledged
String
"1" if our server has sent the callback, "0" otherwise.
status
String
The current status of the transaction (e.g., Approved, Pending, Declined, etc).
post_hash
String
A Base64-encoded encrypted hash to verify the integrity of this response.
Error Response
An error response will contain an error key.
{
"error": "Invalid hash."
}Verify the Response
post_hash
Before trusting any data from the response, you must verify its post_hash to ensure the data is from us and has not been tampered with. This verification logic is identical to the logic used for verifying a callback.
You do not need to send an "Acknowledge" response for a status poll.
4.1 Compute the Local Hash
From the JSON response, get the order_id, received_amount, and status. Concatenate them with your secret_key and create an MD5 hash.
// $response_data is the decoded JSON response from /api/status_polling.php
$order_id = $response_data['order_id'];
$received_amount = $response_data['received_amount'];
$status = $response_data['status'];
$local_hash = md5($order_id . $received_amount . $status . $secret_key);// responseData is the parsed JSON response
const order_id = responseData.order_id;
const received_amount = responseData.received_amount;
const status = responseData.status;
const local_hash = crypto
.createHash('md5')
.update(order_id.toString() + received_amount.toString() + status.toString() + secret_key)
.digest('hex');# response_data is the parsed JSON response (a dict)
order_id = response_data['order_id']
# Ensure values are strings for concatenation
received_amount = str(response_data['received_amount'])
status = response_data['status']
local_hash = hashlib.md5((order_id + received_amount + status + secret_key).encode('utf-8')).hexdigest()4.2 Decrypt the Remote Hash
Get the post_hash string from the JSON response. Base64-decode it, then pass the raw binary data to the decrypt function.
// Get the Base64 string from the response
$post_hash_base64 = $response_data['post_hash'];
// Decode it to get the raw binary ciphertext
$ivHashCiphertext = base64_decode($post_hash_base64);
// Decrypt using the function from the reference section below
$remote_hash = decrypt($ivHashCiphertext, $secret_key);const postHashBase64 = responseData.post_hash;
// Decode it to get the raw binary buffer
const ivHashCiphertext = Buffer.from(postHashBase64, 'base64');
// Decrypt using the function from the reference section below
const remote_hash = decrypt(ivHashCiphertext, secret_key);import base64
post_hash_base64 = response_data['post_hash']
# Decode it to get the raw binary data
ivHashCiphertext = base64.b64decode(post_hash_base64)
# Decrypt using the function from the reference section below
remote_hash = decrypt(ivHashCiphertext, secret_key)The decrypt function for your language is provided in the reference section below.
function decrypt($ivHashCiphertext, $password)
{
$method = "AES-256-CBC";
$iv = substr($ivHashCiphertext, 0, 16);
$hash = substr($ivHashCiphertext, 16, 32);
$ciphertext = substr($ivHashCiphertext, 48);
$key = hash('sha256', $password, true);
if (!hash_equals(hash_hmac('sha256', $ciphertext . $iv, $key, true),$hash))
return null;
return openssl_decrypt($ciphertext, $method, $key,
OPENSSL_RAW_DATA, $iv);
}const crypto = require('crypto');
/**
* @param {Buffer} ivHashCiphertext - The raw binary data (Buffer) after Base64-decoding.
* @param {string} password - Your secret key.
* @returns {string|null} The decrypted plaintext or null on failure.
*/
function decrypt(ivHashCiphertext, password) {
const method = 'aes-256-cbc';
// Extract the initialization vector (first 16 bytes)
const iv = ivHashCiphertext.slice(0, 16);
// Extract the hash (next 32 bytes)
const hash = ivHashCiphertext.slice(16, 48);
// Extract the ciphertext (remaining bytes)
const ciphertext = ivHashCiphertext.slice(48);
// Generate the key using SHA-256 hash of the password
const key = crypto.createHash('sha256').update(password, 'utf8').digest();
// Compute HMAC-SHA256 of (ciphertext || iv) using the key
const hmac = crypto.createHmac('sha256', key)
.update(Buffer.concat([ciphertext, iv]))
.digest();
// Compare the computed HMAC with the extracted hash
if (!crypto.timingSafeEqual(hmac, hash)) {
return null; // Hashes do not match; return null
}
try {
// Decrypt the ciphertext using AES-256-CBC
const decipher = crypto.createDecipheriv(method, key, iv);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final()
]);
return plaintext.toString('utf8'); // Return the decrypted text as a UTF-8 string
} catch (err) {
// Decryption failed; return null
return null;
}
}import hashlib
import hmac
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
def decrypt(ivHashCiphertext, password):
"""
Decrypts the iv/hash/ciphertext blob.
:param ivHashCiphertext: The raw binary data (after base64-decoding).
:param password: Your secret key string.
:return: The decrypted plaintext string, or None on failure.
"""
try:
# 1. Extract the components
iv = ivHashCiphertext[:16]
hash_val = ivHashCiphertext[16:48]
ciphertext = ivHashCiphertext[48:]
# 2. Generate the key
key = hashlib.sha256(password.encode('utf-8')).digest()
# 3. Verify the HMAC
computed_hash = hmac.new(key, ciphertext + iv, hashlib.sha256).digest()
if not hmac.compare_digest(computed_hash, hash_val):
return None
# 4. Decrypt the ciphertext
backend = default_backend()
cipher = Cipher(algorithms.AES(key), modes.CBC(iv), backend=backend)
decryptor = cipher.decryptor()
# Decrypt and unpad
# Using PKCS7 unpadder explicitly is more robust
decrypted_padded_text = decryptor.update(ciphertext) + decryptor.finalize()
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
decrypted_text = unpadder.update(decrypted_padded_text) + unpadder.finalize()
return decrypted_text.decode('utf-8')
except Exception as e:
# Catch any error (e.g., bad padding, bad key) and return None
# print(f"Decryption failed: {e}") # Optional: for debugging
return None4.3 Compare the Hashes
Securely compare the local_hash you just computed with the remote_hash you decrypted. If they match, you can trust the data.
if ($remote_hash !== null && hash_equals($local_hash, $remote_hash)) {
// --- SUCCESS: Data is verified ---
// You can now trust the data and update your database.
// echo "Status: " . $response_data['status'];
} else {
// --- FAILURE: Hash mismatch! ---
// Do NOT trust this data.
}let isVerified = false;
if (remote_hash) {
// Use crypto.timingSafeEqual for secure comparison
try {
const localHashBuffer = Buffer.from(local_hash, 'utf8');
const remoteHashBuffer = Buffer.from(remote_hash, 'utf8');
if (localHashBuffer.length === remoteHashBuffer.length) {
isVerified = crypto.timingSafeEqual(localHashBuffer, remoteHashBuffer);
}
} catch (e) {
// Error during buffer creation (e.g., bad hash)
isVerified = false;
}
}
if (isVerified) {
// --- SUCCESS: Data is verified ---
// console.log("Status:", responseData.status);
} else {
// --- FAILURE: Hash mismatch! ---
}import hmac
isVerified = False
if remote_hash:
# Use hmac.compare_digest for secure comparison
isVerified = hmac.compare_digest(local_hash, remote_hash)
if isVerified:
# --- SUCCESS: Data is verified ---
# print(f"Status: {response_data['status']}")
pass
else:
# --- FAILURE: Hash mismatch! ---
passTRANSACTION STATUS :
Amount Mismatch : We received money but customer paid different money than the requested money
Approved : We received money same value as requested
Late Approved : We recieved money but it happened late while doing reconcilation from bank side
Declined : The transaction declined due to security reasons
Failed : The payment failed from bank side
Cancelled : This status for NonSeamess when customer cancel the payment from the screen
User Timed Out: The user did not complete the payment within the session period.
P2P Accounts Wallet Balance API Overview
This API provides detailed information about an operator's payment gateway (PG) accounts, including balance details and account details. It allows you to query and retrieve information related to UPI, IMPS, IMPS with UPI, wallet and payout accounts linked to the operator. For more details on how to use this API, refer to the link below.
COMPLAINT
We have a dedicated Complaint Section where merchants can manage transaction-related complaints. Through this section, merchants can submit complaints with all necessary details and optional evidence. Upon submission, a unique complaint reference ID is generated, allowing merchants to track the complaint’s status and receive real-time updates via the status-check API. This ensures a smooth, secure, and efficient process for resolving any transaction issues.
RECONCILIATION
This API endpoint allows authorized users to retrieve payment transactions based on a specific pid (Partner ID) and date. The API performs authentication using a token and signature verification to ensure secure communication.
Last updated