
Webhooks
The E-Wallet Top Up Transaction Webhook provides real-time notifications when an E-Wallet Top Up (Money Out) transaction is processed through the Singa Payment Gateway. Use this webhook to track success and failure outcomes for e-wallet disbursement transactions.
| Method | Path | Format | Authentication |
|---|---|---|---|
| POST | https://your-webhook-url/callback | json | HMAC SHA512 Signature |
This webhook sends a POST request to your configured disbursement_notif_url when an E-Wallet Top Up transaction is completed (success or failed).

The disbursement_notif_url configuration can be accessed from the settings page as shown in the screenshot above.
Shared URL: Both the Disbursement (Bank Transfer) webhook and this E-Wallet Top Up webhook are delivered to the same disbursement_notif_url. Use the event field to distinguish between the two types: "disbursement" for bank transfer and "ewallet-topup" for e-wallet top up.
When an E-Wallet Top Up transaction is completed (success or failed), Singa Payment Gateway will send a webhook notification to your registered disbursement_notif_url.
| Field | Value | Type | Mandatory | Length | Description | Example |
|---|---|---|---|---|---|---|
| Content-Type | application/json | Alphabetic | Mandatory | Specifies JSON format for the request body | application/json | |
| User-Agent | SingaPaymentGateway/1.0 | Alphabetic | Mandatory | Identifies the source of the webhook | SingaPaymentGateway/1.0 | |
| Accept | application/json | Alphabetic | Mandatory | Expected response format | application/json | |
| X-PARTNER-ID | Alphanumeric | Mandatory | Your API Key from the merchant dashboard | pk_live_abc123def456 | ||
| X-Signature | Alphanumeric | Mandatory | 128 | HMAC SHA512 signature for request verification | 5f4dcc3b5aa765d61d8327deb882cf99… | |
| X-Timestamp | Numeric | Mandatory | 10 | Unix timestamp in seconds when the request was sent | 1695711945 | |
| Authorization | Bearer <access_token> | Alphanumeric | Mandatory | Bearer token (JWT) for authentication, used as component in signature | Bearer eyJ0eXAiOiJKV1QiLCJ… |
Note: All signature-related headers (X-Signature, X-Timestamp, Authorization) are always included when your merchant account has API credentials configured. For signature validation, extract the access token from the Authorization header and use it as-is in the string to sign. See the Security Mechanisms section below for details.
| Field | Type | Mandatory | Description | Example |
|---|---|---|---|---|
| response_code | String | Mandatory | Response code. Please check appendix 01 | SP000 |
| response_message | String | Mandatory | Response message. Please check appendix 01 | Successfully |
| event | String | Mandatory | Transaction type identifier. Value: ewallet-topup | “ewallet-topup” |
| data | Object | Mandatory | Response payload object | - |
| > transaction_id | String | Mandatory | System-generated transaction ID | “EW1512220251105174015668” |
| > reference_number | String | Mandatory | Merchant’s unique reference number | “REF123456789” |
| > notes | String | Optional | Transaction notes from request | “topup OVO pelanggan” |
| > transaction_status | Object | Mandatory | Transaction status object. Please check appendix 02 | - |
| >> code | String | Mandatory | Status code. 00 = Success, 06 = Failed | “00” |
| >> desc | String | Mandatory | Status description | “Success” |
| > post_timestamp | String | Mandatory | Transaction creation time (Unix milliseconds) | “1766978961000” |
| > processed_timestamp | String | Conditional | Processing completion time (Unix ms, empty if failed) | “1766978962000” |
| > ewallet | Object | Mandatory | E-wallet beneficiary information | - |
| >> code | String | Mandatory | E-wallet provider code (e.g., OVO, DANA, GOPAY, LINKAJA, SHOPEEPAY) | “OVO” |
| >> customer_number | String | Mandatory | Customer’s e-wallet phone number / account number | “08123456789” |
| >> customer_name | String | Conditional | Customer’s name on the e-wallet (null if inquiry not available) | “Budi Santoso” |
| > gross_amount | Object | Mandatory | Total amount deducted from merchant balance (net + fee) | - |
| >> currency | String | Mandatory | Currency code (ISO 4217) | “IDR” |
| >> value | String | Mandatory | Gross amount value | “50000.00” |
| > fee | Object | Mandatory | Admin fee charged for this transaction | - |
| >> currency | String | Mandatory | Currency code (ISO 4217) | “IDR” |
| >> value | String | Mandatory | Fee amount value | “2500.00” |
| > net_amount | Object | Mandatory | Net amount received by the customer (gross minus fee) | - |
| >> currency | String | Mandatory | Currency code (ISO 4217) | “IDR” |
| >> value | String | Mandatory | Net amount value | “47500.00” |
| > balance_after | Object | Mandatory | Merchant account balance after transaction | - |
| >> currency | String | Conditional | Currency code (empty if unavailable) | “IDR” |
| >> value | String | Conditional | Balance value (empty if unavailable) | “750000” |
| > failed_code | String | Conditional | Error code (only present when transaction failed) | “CONNECTION_ERROR” |
| > failed_reason | String | Conditional | Error description (only present when transaction failed) | “Transaction failed at vendor” |
Success: Here’s an example of a successful E-Wallet Top Up transaction.
{
"response_code": "SP000",
"response_message": "Successfully",
"event": "ewallet-topup",
"data": {
"transaction_id": "EW101222025122910292195055674",
"reference_number": "REF-EWALLET-001",
"notes": "topup OVO pelanggan",
"transaction_status": {
"code": "00",
"desc": "Success"
},
"post_timestamp": "1766978961000",
"processed_timestamp": "1766978962000",
"ewallet": {
"code": "OVO",
"customer_number": "08123456789",
"customer_name": "Budi Santoso"
},
"gross_amount": {
"currency": "IDR",
"value": "50000.00"
},
"fee": {
"currency": "IDR",
"value": "2500.00"
},
"net_amount": {
"currency": "IDR",
"value": "47500.00"
},
"balance_after": {
"currency": "IDR",
"value": "750000"
}
}
}
Failed: Here’s an example of a failed E-Wallet Top Up transaction.
{
"response_code": "SP001",
"response_message": "Transaction Failure",
"event": "ewallet-topup",
"data": {
"transaction_id": "EW121222025122617513896515436",
"reference_number": "REF-EWALLET-002",
"notes": "topup DANA pelanggan",
"transaction_status": {
"code": "06",
"desc": "Failed"
},
"post_timestamp": "1766978900000",
"processed_timestamp": "",
"ewallet": {
"code": "DANA",
"customer_number": "08198765432",
"customer_name": null
},
"gross_amount": {
"currency": "IDR",
"value": "100000.00"
},
"fee": {
"currency": "IDR",
"value": "2500.00"
},
"net_amount": {
"currency": "IDR",
"value": "97500.00"
},
"balance_after": {
"currency": "IDR",
"value": "850000"
},
"failed_code": "CONNECTION_ERROR",
"failed_reason": "Connection timeout to vendor"
}
}
Since both Disbursement (Bank Transfer) and E-Wallet Top Up webhooks arrive at the same disbursement_notif_url, use the event field to determine the transaction type before processing.
Recommended approach (using event field):
<?php
$payload = json_decode(file_get_contents('php://input'), true);
$event = $payload['event'] ?? null;
if ($event === 'ewallet-topup') {
// E-Wallet Top Up webhook
$ewalletCode = $payload['data']['ewallet']['code'];
$customerNumber = $payload['data']['ewallet']['customer_number'];
$netAmount = $payload['data']['net_amount']['value'];
$status = $payload['data']['transaction_status']['code']; // "00" or "06"
// handle e-wallet top up...
} elseif ($event === 'disbursement') {
// Disbursement (Bank Transfer) webhook
$bankCode = $payload['data']['bank']['code'];
$accountNo = $payload['data']['bank']['account_number'];
$netAmount = $payload['data']['net_amount']['value'];
$status = $payload['data']['transaction_status']['code']; // "00" or "06"
// handle disbursement...
} elseif (isset($payload['data']['bank']) && !isset($payload['event'])) {
// Optional legacy fallback: very old disbursement callbacks without `event`
$bankCode = $payload['data']['bank']['code'];
$netAmount = $payload['data']['net_amount']['value'];
// handle disbursement...
}
http_response_code(200);
echo json_encode(['status' => 'success']);
?>
| Response Code | Response Message | Description | Merchant Action |
|---|---|---|---|
| SP000 | Successfully | Success | Need check inquiry status |
| SP001 | Transaction Failure | Failed Transaction | Need check inquiry status |
| SP002 | General Failure | Internal Server Error | Need check inquiry status |
| SP003 | Insufficient Balance | Running out of account balance | Need check inquiry status |
| SP004 | Duplicate Reference Number | Duplicate Reference Number | - |
| SP005 | Timeout | Gateway timeout | Need check inquiry status |
| SP006 | Exceed Beneficiary Limit | Beneficiary has exceeded the amount transaction limit | Need check inquiry status |
| SP007 | Exceed Account Limit | Your account reached transaction limit | Please contact IT Support |
| SP008 | Invalid Reference Number | Reference Number does not exists | - |
| SP009 | Transaction Not Found | Transaction Not Found | - |
| SP010 | Beneficiary Account Not Found | Beneficiary Account Not Found | - |
| SP011 | Beneficiary Vendor Not Active | Beneficiary Vendor Not Active | - |
| SP012 | Bad Request | Bad Request | - |
| SP013 | Unauthorized | Unauthorized | - |
| SP014 | Not Found | Not Found | - |
| SP015 | Forbidden | Forbidden | - |
| SP016 | Signature Invalid | Signature Invalid | - |
| SP017 | Unauthorized IP | Your IP Not Authorized | Please contact IT Support |
| SP018 | Validation error | Payload request validation error | - |
| SP019 | General Error | General Error | Please contact IT Support |
| SP020 | Merchant Account Not Found | Merchant Account Not Found | - |
| Code | Desc | Notes |
|---|---|---|
| 00 | Success | Succeed |
| 01 | Initiated | Payment call has not been received, and payment retry is possible |
| 02 | Paying | Pending/Suspect: - can retry to the check status after next few minutes - Waiting callback for final status |
| 03 | Pending | Pending/Suspect: - can retry to the check status after next few minutes - Waiting callback for final status |
| 04 | Refunded | Reversal transaction |
| 05 | Canceled | Please create new transaction |
| 06 | Failed | Failed Transaction |
| 07 | Not Found | Please create new transaction |
To ensure the security and authenticity of webhook requests from Singa Payment Gateway, we provide two recommended security mechanisms. You can choose one or combine both for maximum protection.
Important Note: Signature validation is optional but highly recommended. You can secure your webhook endpoint using either IP whitelisting, signature validation, or both methods together.
This is the simplest security method where you restrict webhook access to only authorized IP addresses from Singa Payment Gateway.
How it works:
Pros:
Cons:
Implementation Example (Nginx):
location /webhook/ewallet-topup {
# Only allow Singa Payment Gateway IPs
allow 103.xxx.xxx.xxx; # Replace with actual IPs from Singa
allow 103.xxx.xxx.xxx; # Replace with actual IPs from Singa
deny all;
proxy_pass http://your-backend;
}
Implementation Example (PHP):
<?php
// Define allowed IPs (get these from Singa Payment Gateway)
$allowedIPs = [
'103.xxx.xxx.xxx',
'103.xxx.xxx.xxx',
];
$requestIP = $_SERVER['REMOTE_ADDR'];
if (!in_array($requestIP, $allowedIPs)) {
http_response_code(403);
echo json_encode(['status' => 'error', 'message' => 'Access denied']);
exit;
}
// Process webhook...
?>
This method uses cryptographic signatures to verify that:
How it works:
Pros:
Cons:
When to use: Production environments, handling sensitive transactions, or when maximum security is required.
See the detailed implementation guide in the “How to Validate Signature” section below.
For production environments, we strongly recommend using both IP whitelisting and signature validation together.
Implementation order:
Benefits:
Example Implementation:
<?php
// Layer 1: IP Whitelist
$allowedIPs = ['103.xxx.xxx.xxx', '103.xxx.xxx.xxx'];
$requestIP = $_SERVER['REMOTE_ADDR'];
if (!in_array($requestIP, $allowedIPs)) {
http_response_code(403);
exit;
}
// Layer 2: Signature Validation
$requestBody = file_get_contents('php://input');
$headers = getallheaders();
$clientSecret = 'your-client-secret';
$endpoint = '/webhook/ewallet-topup';
if (!validateWebhookSignature($requestBody, $headers, $clientSecret, $endpoint)) {
http_response_code(401);
exit;
}
// Both checks passed - process webhook
$payload = json_decode($requestBody, true);
// ... process transaction ...
?>
| Recommended Approach | Reason |
|---|---|
| IP Whitelist only | Simple to implement, easy to debug, suitable for testing environments |
| Signature validation only | Higher security, good for testing signature implementation |
| IP Whitelist + Signature ⭐ | Maximum security, industry best practice, recommended for production |
| IP Whitelist + Signature + Timestamp validation | Additional protection against replay attacks for high-security requirements |
To implement IP whitelisting, contact our support team or check your merchant dashboard for the official list of Singa Payment Gateway IP addresses.
Note: We will notify you in advance if our IP addresses change.
The X-Signature header is a security mechanism that ensures the webhook request is authentic and comes from Singa Payment Gateway. This signature is generated using HMAC SHA512 algorithm.
Note: While signature validation is optional, we strongly recommend implementing it, especially for production environments, to ensure maximum security and data integrity.
E-Wallet Top Up webhooks use the exact same HMAC SHA512 signature scheme as Disbursement (Bank Transfer) webhooks.
The signature is created using a multi-step process that combines the request method, endpoint, access token, hashed body, and timestamp.
Extract the following headers from the incoming webhook request:
Retrieve your Client Secret from the merchant dashboard. This is the same secret used for API authentication and is required as the HMAC key for signature verification.
Important: The Client Secret must be kept secure and never exposed in client-side code or logs.
Extract the endpoint path from your webhook URL. For example:
https://yourdomain.com/webhook/ewallet-topup?param=value/webhook/ewallet-topup?param=valueThe endpoint includes the path and any query parameters.
The request body must be normalized before hashing to ensure consistent results:
JSON_UNESCAPED_UNICODE - Don’t escape Unicode charactersJSON_UNESCAPED_SLASHES - Don’t escape forward slashesExample:
Original JSON: {"event":"ewallet-topup","response_code":"SP000","data":{"transaction_id":"EW123"}}
After sorting: {"data":{"transaction_id":"EW123"},"event":"ewallet-topup","response_code":"SP000"}
SHA-256 Hash: 5f4dcc3b5aa765d61d8327deb882cf99acd3d28e5cf0e661c02c8e8e6e8e6f9a
Concatenate the following values with colon (:) as separator:
StringToSign = METHOD + ":" + ENDPOINT + ":" + ACCESS_TOKEN + ":" + HASHED_BODY + ":" + TIMESTAMP
Example:
Method: POST
Endpoint: /webhook/ewallet-topup
Access Token: eyJ0eXAiOiJKV1QiLCJhbGci...
Hashed Body: 5f4dcc3b5aa765d61d8327deb882cf99acd3d28e5cf0e661c02c8e8e6e8e6f9a
Timestamp: 1695711945
StringToSign = POST:/webhook/ewallet-topup:eyJ0eXAiOiJKV1QiLCJhbGci...:5f4dcc3b5aa765d61d8327deb882cf99acd3d28e5cf0e661c02c8e8e6e8e6f9a:1695711945
Use your Client Secret as the HMAC key and hash the string to sign:
Calculated Signature = HMAC-SHA512(StringToSign, Client Secret)
Compare the calculated signature with the signature from the X-Signature header using a constant-time comparison to prevent timing attacks.
Important: Use hash_equals() in PHP, crypto.timingSafeEqual() in Node.js, or hmac.compare_digest() in Python for secure comparison.
if (hash_equals($calculatedSignature, $receivedSignature)) {
// Signature is valid - process webhook
} else {
// Signature is invalid - reject request
}
<?php
/**
* Validate webhook signature from Singa Payment Gateway
*/
function validateWebhookSignature($requestBody, $headers, $clientSecret, $endpoint) {
// Step 1: Extract headers
$receivedSignature = $headers['X-Signature'] ?? '';
$timestamp = $headers['X-Timestamp'] ?? '';
$authorization = $headers['Authorization'] ?? '';
// Extract access token from "Bearer {token}"
$accessToken = str_replace('Bearer ', '', $authorization);
// Step 2: Normalize and hash the request body
$bodyArray = json_decode($requestBody, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return false; // Invalid JSON
}
// Sort keys recursively
function sortRecursive(&$array) {
ksort($array, SORT_STRING);
foreach ($array as &$value) {
if (is_array($value)) {
sortRecursive($value);
}
}
}
sortRecursive($bodyArray);
// Re-encode with specific flags
$normalizedJson = json_encode($bodyArray, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// Hash with SHA-256
$hashedBody = hash('sha256', $normalizedJson);
// Step 3: Build string to sign
$method = 'POST'; // Webhooks always use POST
$stringToSign = "{$method}:{$endpoint}:{$accessToken}:{$hashedBody}:{$timestamp}";
// Step 4: Generate HMAC SHA512 signature
$calculatedSignature = hash_hmac('sha512', $stringToSign, $clientSecret);
// Step 5: Compare signatures using constant-time comparison
return hash_equals($calculatedSignature, $receivedSignature);
}
// Usage example
$requestBody = file_get_contents('php://input');
$headers = getallheaders();
$clientSecret = 'your-client-secret-from-dashboard'; // Get from merchant dashboard
$endpoint = '/webhook/ewallet-topup'; // Your webhook endpoint path
if (validateWebhookSignature($requestBody, $headers, $clientSecret, $endpoint)) {
// Signature is valid - process webhook
$payload = json_decode($requestBody, true);
$event = $payload['event']; // "ewallet-topup"
$transactionId = $payload['data']['transaction_id'];
$referenceNumber = $payload['data']['reference_number'];
$statusCode = $payload['data']['transaction_status']['code']; // "00" or "06"
$netAmount = $payload['data']['net_amount']['value'];
// Update your database, send notifications, etc.
// Return success response
http_response_code(200);
echo json_encode(['status' => 'success']);
} else {
// Signature is invalid - reject request
http_response_code(401);
echo json_encode(['status' => 'error', 'message' => 'Invalid signature']);
}
?>
import hmac
import hashlib
import json
from flask import Flask, request, jsonify
app = Flask(__name__)
def sort_dict_recursive(data):
"""Recursively sort dictionary keys"""
if isinstance(data, dict):
return {k: sort_dict_recursive(v) for k, v in sorted(data.items())}
elif isinstance(data, list):
return [sort_dict_recursive(item) for item in data]
else:
return data
def validate_webhook_signature(request_body, headers, client_secret, endpoint):
"""Validate webhook signature from Singa Payment Gateway"""
# Step 1: Extract headers
received_signature = headers.get('X-Signature', '')
timestamp = headers.get('X-Timestamp', '')
authorization = headers.get('Authorization', '')
# Extract access token from "Bearer {token}"
access_token = authorization.replace('Bearer ', '')
# Step 2: Parse and normalize JSON
try:
body_dict = json.loads(request_body)
except json.JSONDecodeError:
return False
# Sort keys recursively
sorted_body = sort_dict_recursive(body_dict)
# Re-encode with specific settings
normalized_json = json.dumps(sorted_body, ensure_ascii=False, separators=(',', ':'))
# Hash with SHA-256
hashed_body = hashlib.sha256(normalized_json.encode('utf-8')).hexdigest()
# Step 3: Build string to sign
method = 'POST' # Webhooks always use POST
string_to_sign = f"{method}:{endpoint}:{access_token}:{hashed_body}:{timestamp}"
# Step 4: Generate HMAC SHA512 signature
calculated_signature = hmac.new(
client_secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
hashlib.sha512
).hexdigest()
# Step 5: Compare signatures using constant-time comparison
return hmac.compare_digest(calculated_signature, received_signature)
@app.route('/webhook/ewallet-topup', methods=['POST'])
def webhook_ewallet_topup():
"""Handle E-Wallet Top Up transaction webhook"""
request_body = request.get_data(as_text=True)
headers = dict(request.headers)
client_secret = 'your-client-secret-from-dashboard' # Get from merchant dashboard
endpoint = '/webhook/ewallet-topup' # Your webhook endpoint path
if validate_webhook_signature(request_body, headers, client_secret, endpoint):
# Signature is valid - process webhook
payload = json.loads(request_body)
event = payload.get('event') # "ewallet-topup"
transaction_id = payload['data']['transaction_id']
reference_number = payload['data']['reference_number']
status_code = payload['data']['transaction_status']['code'] # "00" or "06"
net_amount = payload['data']['net_amount']['value']
# Update your database, send notifications, etc.
# Return success response
return jsonify({'status': 'success'}), 200
else:
# Signature is invalid - reject request
return jsonify({'status': 'error', 'message': 'Invalid signature'}), 401
if __name__ == '__main__':
app.run(port=3000)
const express = require('express');
const crypto = require('crypto');
const app = express();
// Middleware to capture raw body for signature validation
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
}
}));
/**
* Recursively sort object keys
*/
function sortObjectRecursive(obj) {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(sortObjectRecursive);
}
const sorted = {};
Object.keys(obj).sort().forEach(key => {
sorted[key] = sortObjectRecursive(obj[key]);
});
return sorted;
}
/**
* Validate webhook signature from Singa Payment Gateway
*/
function validateWebhookSignature(requestBody, headers, clientSecret, endpoint) {
// Step 1: Extract headers
const receivedSignature = headers['x-signature'] || '';
const timestamp = headers['x-timestamp'] || '';
const authorization = headers['authorization'] || '';
// Extract access token from "Bearer {token}"
const accessToken = authorization.replace('Bearer ', '');
// Step 2: Parse and normalize JSON
let bodyObject;
try {
bodyObject = JSON.parse(requestBody);
} catch (e) {
return false; // Invalid JSON
}
// Sort keys recursively
const sortedBody = sortObjectRecursive(bodyObject);
// Re-encode JSON (no escaping for unicode and slashes)
const normalizedJson = JSON.stringify(sortedBody);
// Hash with SHA-256
const hashedBody = crypto
.createHash('sha256')
.update(normalizedJson)
.digest('hex');
// Step 3: Build string to sign
const method = 'POST'; // Webhooks always use POST
const stringToSign = `${method}:${endpoint}:${accessToken}:${hashedBody}:${timestamp}`;
// Step 4: Generate HMAC SHA512 signature
const calculatedSignature = crypto
.createHmac('sha512', clientSecret)
.update(stringToSign)
.digest('hex');
// Step 5: Compare signatures using constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(calculatedSignature),
Buffer.from(receivedSignature)
);
}
// Webhook endpoint
app.post('/webhook/ewallet-topup', (req, res) => {
const requestBody = req.rawBody;
const headers = req.headers;
const clientSecret = 'your-client-secret-from-dashboard'; // Get from merchant dashboard
const endpoint = '/webhook/ewallet-topup'; // Your webhook endpoint path
if (validateWebhookSignature(requestBody, headers, clientSecret, endpoint)) {
// Signature is valid - process webhook
const payload = req.body;
const event = payload.event; // "ewallet-topup"
const transactionId = payload.data.transaction_id;
const referenceNumber = payload.data.reference_number;
const statusCode = payload.data.transaction_status.code; // "00" or "06"
const netAmount = payload.data.net_amount.value;
// Update your database, send notifications, etc.
// Return success response
res.status(200).json({ status: 'success' });
} else {
// Signature is invalid - reject request
res.status(401).json({ status: 'error', message: 'Invalid signature' });
}
});
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});
Best Practices
hash_equals(), crypto.timingSafeEqual(), or hmac.compare_digest())client_secret as the HMAC keyCommon Mistakes to Avoid
:, not _ or -)If signature validation fails:
METHOD:ENDPOINT:ACCESS_TOKEN:HASHED_BODY:TIMESTAMPreference_number to detect duplicates)Your webhook endpoint must return an appropriate HTTP response:
When the webhook is processed successfully:
Status Code: 200 OK
{
"status": "success"
}
Invalid Signature (401 Unauthorized):
{
"status": "error",
"message": "Invalid signature"
}
Processing Error (500 Internal Server Error):
{
"status": "error",
"message": "Failed to process webhook"
}
Important: Singa Payment Gateway will retry failed webhooks (non-200 responses) up to 3 times with exponential backoff.
E-Wallet Top Up transactions use reference_number for idempotency:
reference_number can only be used once per accountreference_number, you will receive the existing transaction statusreference_number before processing to avoid duplicate handlingExample — detecting duplicate webhook:
$referenceNumber = $payload['data']['reference_number'];
$existingTransaction = findTransactionByReference($referenceNumber);
if ($existingTransaction) {
// Already processed — return success without re-processing
http_response_code(200);
return;
}
// Process new transaction
processEwalletTopUp($payload);
Important: E-Wallet Top Up webhooks use two different timestamp formats:
X-Timestamp header (for signature validation): Unix timestamp in seconds
1695711945post_timestamp & processed_timestamp (in request body): Unix timestamp in milliseconds
1766978961000Converting millisecond timestamps:
// Convert to DateTime
$postTimestamp = $payload['data']['post_timestamp']; // "1766978961000"
$date = new DateTime();
$date->setTimestamp((int)($postTimestamp / 1000)); // Divide by 1000 to get seconds
// Or use Carbon (Laravel)
$date = \Carbon\Carbon::createFromTimestampMs($postTimestamp);
Important: When validating signatures, always use the X-Timestamp header value (in seconds), NOT the timestamps from the request body (which are in milliseconds).
When an E-Wallet Top Up fails, Singa Payment Gateway automatically refunds the full gross_amount (net + fee) back to your merchant balance. You do not need to request a manual refund — the balance is restored immediately upon failure detection. The balance_after field reflects the post-refund balance.
The balance_after field shows your account balance after the transaction: