Callback
Parameters and structure of the callback notification.
Callback
When a buyer completes a payment, iPaymu will send a callback notification to your configured endpoint. This document covers the complete callback structure, required headers, and signature validation process.
Configuration
You can configure the callback content type in your iPaymu dashboard. Choose the format that works best for your system:
| Content-Type | Description |
|---|---|
application/x-www-form-urlencoded | Default. Recommended for better stability and compatibility |
application/json | Alternative format if your system prefers JSON |
Settings Links:
- Production: https://my.ipaymu.com/integration/setting
- Sandbox: https://sandbox.ipaymu.com/integration/setting
Secret Key for Signature Validation
To perform signature validation on callbacks, use your VA (Virtual Account) Number from your iPaymu account as the secret key.
How to get the Secret Key:
- Login to your iPaymu Dashboard
- Select Integration or Settings menu
- Click API Key
- Your VA Number will be displayed in the API Key or Merchant VA section
Example Secret Key: 1179001234567890
⚠️ Important: Never share your VA Number or store it in public repositories. Use environment variables to store the secret key in production.
Callback Flow Diagram
Here is the complete flow diagram of the callback process from iPaymu:
Important Notes:
- If signature is invalid, don't process the transaction
- Always respond with HTTP 200 to stop retries
- Implement idempotency to avoid duplication
Important Notes
Before implementing callback handling, make sure you understand these key points:
| Topic | Description |
|---|---|
| Retry Mechanism | iPaymu will automatically retry the callback if your server doesn't respond with 200 OK. Set up proper idempotency handling to avoid duplicate processing. |
| Idempotency | Since retries may occur, always check transaction status in your database before processing. This prevents double-posting when the same callback arrives multiple times. |
| Signature Validation | Always validate the X-Signature header to confirm the request genuinely comes from iPaymu. Never process callbacks without verification. |
| Secret Key | Use your Merchant VA Number as the secret key for signature validation. |
Headers
Every callback request includes these headers. The X-Signature is crucial for security verification.
| Header | Value | Description |
|---|---|---|
Accept | application/json | The content type iPaymu expects for responses |
Content-Type | application/json | Format of the request body |
X-External-ID | 20251111101052209120 | Unique identifier for this callback request |
X-Signature | c9594cc8... | HMAC-SHA256 signature for validation |
X-Timestamp | 2025-11-11T10:10:52+07:00 | Timestamp when the callback was generated |
Complete Payload Examples
Here are complete payload examples sent by iPaymu:
x-www-form-urlencoded Example
POST /callback HTTP/1.1
Host: your-domain.com
Content-Type: application/x-www-form-urlencoded
X-Signature: c9594cc8...
X-Timestamp: 2025-11-11T10:10:52+07:00
buyer_email=customer%40example.com&
buyer_name=John%20Doe&
buyer_phone=081234567890&
created_at=2025-11-11%2010%3A00%3A00&
expired_at=2025-11-11%2011%3A00%3A00&
fee=1500&
paid_at=2025-11-11%2010%3A10%3A52&
reference_id=ORDER123456&
sid=SESSION789&
status=berhasil&
status_code=1&
status_desc=Success&
channel=bca&
paid_off=98500&
product=Product%20Name&
quantity=1&
merchant=1179001234567890&
merchant_name=Your%20Store&
system_notes=Payment%20completed&
trscode=TRX789012&
trx_id=12345678&
unique_code=0&
via=va&
payment_no=1234567890&
va=1234567890&
url=https%3A%2F%2Fyour-domain.com%2Fcallback&
additional_info=%5B%5D&
transaction_status_code=1&
settlement_status=unsettle&
settlement_date=null&
expired_time=3600&
expired_unix=1762842000&
is_escrow=0&
is_refund=0&
is_sandbox=false&
total=100000&
amount=100000&
sub_total=100000&
referenceId=ORDER123456application/json Example
{
"buyer_email": "customer@example.com",
"buyer_name": "John Doe",
"buyer_phone": "081234567890",
"created_at": "2025-11-11 10:00:00",
"expired_at": "2025-11-11 11:00:00",
"fee": "1500",
"paid_at": "2025-11-11 10:10:52",
"reference_id": "ORDER123456",
"sid": "SESSION789",
"status": "berhasil",
"status_code": "1",
"status_desc": "Success",
"channel": "bca",
"paid_off": "98500",
"product": "Product Name",
"quantity": "1",
"merchant": "1179001234567890",
"merchant_name": "Your Store",
"system_notes": "Payment completed",
"trscode": "TRX789012",
"trx_id": "12345678",
"unique_code": "0",
"via": "va",
"payment_no": "1234567890",
"va": "1234567890",
"url": "https://your-domain.com/callback",
"additional_info": [],
"transaction_status_code": "1",
"settlement_status": "unsettle",
"settlement_date": null,
"expired_time": "3600",
"expired_unix": "1762842000",
"is_escrow": "0",
"is_refund": "0",
"is_sandbox": "false",
"total": "100000",
"amount": "100000",
"sub_total": "100000",
"referenceId": "ORDER123456"
}Callback Data Structure
The following table shows all possible fields in a callback response. Pay close attention to the data types — using the wrong type during signature validation will cause hash mismatches.
Core Transaction Fields
| Field | Type | Description | Validation Notes |
|---|---|---|---|
trx_id | Integer | Unique transaction ID in iPaymu system | Convert from String if receiving via Form Data |
sid | String | Transaction session ID | - |
reference_id | String | Your merchant reference ID | - |
status | String | Text status: berhasil, pending, expired | - |
status_code | Integer | Status code: 1 (Success), 0 (Pending), -2 (Expired) | Convert from String "1" to Integer 1 |
Amount Fields
| Field | Type | Description | Validation Notes |
|---|---|---|---|
sub_total | String | Net transaction amount | Leave as String |
total | String | Gross transaction amount | Leave as String |
amount | String | Total transaction amount | Leave as String |
fee | String | Admin fee | Leave as String |
paid_off | Integer | Net amount received (amount - fee) | Convert from String to Integer |
Timestamp Fields
| Field | Type | Description | Validation Notes |
|---|---|---|---|
created_at | String | Transaction creation time | Format: YYYY-MM-DD HH:MM:SS |
expired_at | String | Transaction expiration time | - |
paid_at | String | Payment completion time | - |
settlement_status | String | Settlement status | settled or unsettle |
Status Fields
| Field | Type | Description | Validation Notes |
|---|---|---|---|
transaction_status_code | Integer | Original status code | Convert from String to Integer |
is_escrow | Boolean | Escrow indicator | Convert String "0" to false, "1" to true |
Payment Details
| Field | Type | Description | Validation Notes |
|---|---|---|---|
via | String | Payment method (e.g., va) | - |
channel | String | Payment channel (e.g., bca, mandiri) | - |
payment_no | String | VA or payment number | Important: Keep as String to preserve leading zeros |
va | String | Merchant VA number | Same as payment_no. Only present for VA transactions |
system_notes | String | System-generated notes | - |
Buyer Information
| Field | Type | Description | Validation Notes |
|---|---|---|---|
buyer_name | String | Buyer name | - |
buyer_email | String | Buyer email | - |
buyer_phone | String | Buyer phone number | - |
Additional Fields
| Field | Type | Description | Validation Notes |
|---|---|---|---|
additional_info | Array | Additional information | Required: Include [] even if field is not sent |
url | String | Callback URL | - |
Conditional Fields
Some fields behave differently depending on the payment method. Here's what to expect:
| Field | Behavior |
|---|---|
transaction_status_code | 1 = Payment settled immediately. 6 = Payment received (settlement may be processed separately) |
payment_no | Populated for VA transactions. Empty for QRIS and e-wallet payments |
va | Only present for VA transactions. Not included for QRIS and e-wallet payments |
Signature Validation
To ensure callbacks genuinely come from iPaymu, you must validate the signature on every request. Here's the step-by-step process:
Validation Steps
- Extract Data — Get all data from
req.body - Normalize Types — Convert values to their correct data types
- String to Integer:
trx_id,status_code,transaction_status_code,paid_off - String to Boolean:
is_escrow - String
[]to Array[]:additional_info
- String to Integer:
- Handle Missing Fields — If
additional_infois missing (common withx-www-form-urlencoded), add it manually as an empty array - Sort Keys — Sort all keys in Ascending (A-Z) order with case sensitivity
- Convert to JSON — Serialize the sorted object to a JSON string
- URL Escape (Optional) — Escape forward slashes (
/→\/) if following strict PHPjson_encodestandards - Generate Hash — Create HMAC-SHA256 using the JSON string and your secret key (VA number)
- Compare — Match your generated hash against the
X-Signatureheader
Example Code Implementation
import crypto from 'crypto';
import express from 'express';
const app = express();
app.use(express.urlencoded({ extended: true }));
function normalizeData(rawData) {
const result = {};
for (const key in rawData) {
let val = rawData[key];
if (key === 'is_escrow') {
result[key] = (val === 'true' || val === '1' || val === 1);
}
else if (['trx_id', 'status_code', 'transaction_status_code', 'paid_off'].includes(key)) {
result[key] = parseInt(val, 10);
}
else if (key === 'additional_info') {
if (val === '[]') result[key] = [];
else result[key] = val;
}
else {
result[key] = String(val);
}
}
if (!result.hasOwnProperty('additional_info')) {
result['additional_info'] = [];
}
return result;
}
function phpKsort(obj) {
return Object.keys(obj)
.sort((a, b) => a.localeCompare(b))
.reduce((sortedObj, key) => {
sortedObj[key] = obj[key];
return sortedObj;
}, {});
}
app.post('/callback', (req, res) => {
const secretKey = '123456'; // Your iPaymu account VA Number (see Dashboard > Integration > API Key)
const receivedSignature = req.headers['x-signature'];
// Step 1 & 2: Get and normalize data
const normalizedData = normalizeData(req.body);
if (normalizedData.signature) delete normalizedData.signature;
// Step 3 & 4: Sort keys
const sortedData = phpKsort(normalizedData);
// Step 5 & 6: Convert to JSON with slash escaping
let jsonBody = JSON.stringify(sortedData);
jsonBody = jsonBody.replace(/\//g, '\\/');
// Step 7: Generate hash
const calculatedSignature = crypto
.createHmac('sha256', secretKey)
.update(jsonBody)
.digest('hex');
// Step 8: Compare signatures
if (calculatedSignature === receivedSignature) {
console.log('✅ Signature valid');
// TODO: Update transaction status in database
// TODO: Ensure idempotency - check if already processed
res.status(200).json({ status: 'OK' });
} else {
console.log('❌ Invalid signature');
console.log('Expected:', receivedSignature);
console.log('Got: ', calculatedSignature);
res.status(400).send('Invalid Signature');
}
});
app.listen(PORT, () => {
console.log('═══════════════════════════════════════════════════');
console.log(' iPaymu Callback Handler - URL-encoded');
console.log('═══════════════════════════════════════════════════');
console.log(` Server running on http://localhost:${PORT}`);
console.log(` Endpoint: POST http://localhost:${PORT}/callback`);
console.log('═══════════════════════════════════════════════════');
});Debugging Guide
If signature validation keeps failing, follow these debugging steps:
1. Log All Data
// Add detailed logging in your callback handler
app.post('/callback', (req, res) => {
console.log('=== CALLBACK DEBUG ===');
console.log('Headers:', JSON.stringify(req.headers, null, 2));
console.log('Body (raw):', req.body);
console.log('Content-Type:', req.headers['content-type']);
console.log('X-Signature:', req.headers['x-signature']);
// Continue validation...
});2. Check JSON Output
// Print the JSON used for signature generation
const sortedData = phpKsort(normalizedData);
let jsonBody = JSON.stringify(sortedData);
jsonBody = jsonBody.replace(/\//g, '\\/');
console.log('JSON for signature:');
console.log(jsonBody);
// Output must exactly match what iPaymu expects3. Compare Byte by Byte
// If signature doesn't match, compare character by character
function compareSignatures(sig1, sig2) {
console.log('Signature comparison:');
console.log('Expected:', sig1);
console.log('Got: ', sig2);
for (let i = 0; i < Math.max(sig1.length, sig2.length); i++) {
const c1 = sig1[i] || 'N/A';
const c2 = sig2[i] || 'N/A';
if (c1 !== c2) {
console.log(`Mismatch at index ${i}: '${c1}' vs '${c2}'`);
}
}
}Common Mistakes
Here are common mistakes when implementing callbacks:
1. Not Converting Data Types
Wrong:
const data = req.body; // Use directly without normalizationCorrect:
const normalizedData = normalizeData(req.body);
// Ensure trx_id, status_code, etc. are Integer types2. Incorrect Key Sorting
Wrong:
const sorted = Object.keys(data).sort(); // May be case-insensitiveCorrect:
const sorted = Object.keys(data).sort((a, b) => a.localeCompare(b));3. Not Escaping Slashes in JSON
Wrong:
const jsonBody = JSON.stringify(sortedData); // URL stays http://...Correct:
let jsonBody = JSON.stringify(sortedData);
jsonBody = jsonBody.replace(/\//g, '\\/'); // URL becomes http:\/\/...4. Missing additional_info
Wrong:
// Not adding additional_info if not in requestCorrect:
if (!result.hasOwnProperty('additional_info')) {
result['additional_info'] = [];
}5. Using Wrong Secret Key
Wrong:
const secretKey = 'api_key_or_other'; // Not VA NumberCorrect:
const secretKey = '123456'; // Your iPaymu account VA Number (see Dashboard > Integration > API Key)Implementation Checklist
Before going live, make sure you have:
- Configured callback URL in iPaymu dashboard
- Using VA Number as secret key
- Implemented correct data type normalization
- Sorting keys in ascending order (A-Z)
- Escaping forward slashes in JSON
- Responding with HTTP 200 for valid requests
- Implemented idempotency (duplicate check)
- Logging all callbacks for debugging
- Tested with dummy data from iPaymu
- Have fallback mechanism if callback fails
Source: iPaymu Callback Documentation