Singapay Home Page
Logo Icon
  1. Webhooks
  2. Disbursement Transaction

Information

MethodPathFormatAuthentication
POSThttps://your-webhook-url/callbackjsonHMAC SHA512 Signature

This webhook sends a POST request to your configured disbursement_notif_url when a Disbursement transaction is successfully processed or fails.

Disbursement Notification URL Configuration

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 the E-Wallet Top Up webhook are delivered to the same disbursement_notif_url. Use the event field to distinguish them: "disbursement" for bank transfer and "ewallet-topup" for e-wallet top up. You can also rely on data.bank versus data.ewallet in the payload.

Request Details

When a Disbursement transaction is completed (success or failed), Singa Payment Gateway will send a webhook notification to your registered callback URL. The request includes security headers for verification.

Headers Structure

FieldValueTypeMandatoryLengthDescriptionExample
Content-Typeapplication/jsonAlphabeticMandatorySpecifies JSON format for the request bodyapplication/json
User-AgentSingaPaymentGateway/1.0AlphabeticMandatoryIdentifies the source of the webhookSingaPaymentGateway/1.0
Acceptapplication/jsonAlphabeticMandatoryExpected response formatapplication/json
X-PARTNER-IDAlphanumericMandatoryYour API Key from the merchant dashboardpk_live_abc123def456
X-SignatureAlphanumericMandatory128HMAC SHA512 signature for request verification5f4dcc3b5aa765d61d8327deb882cf99…
X-TimestampNumericMandatory10Unix timestamp in seconds when the request was sent1695711945
AuthorizationBearer <access_token>AlphanumericMandatoryBearer token (JWT) for authentication, used as component in signatureBearer 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.


Body Structure

FieldTypeMandatoryLengthDescriptionExample
response_codeStringMandatory5Response code. Please check appendix 01SP000
response_messageStringMandatory50Response message. Please check appendix 01Successfully
eventStringMandatory-Transaction type identifier. Always "disbursement" for bank transfer (same URL as e-wallet; e-wallet sends "ewallet-topup")"disbursement"
dataObjectMandatory-Response payload object-
> transaction_idStringMandatory-System-generated transaction ID"1512220251105174015668"
> reference_numberStringMandatory-Merchant’s unique reference number"123456789123"
> transaction_statusObjectMandatory-Transaction status. Please check appendix 02-
>> codeNumericMandatory-Status code. I.e: 00, 01, 06, etc"00"
>> descStringMandatory-Status description. I.e: Success, Initiated, Failed, etc"Success"
> post_timestampStringMandatory-Transaction creation time (Unix milliseconds)"1762339215000"
> processed_timestampStringConditional-Processing completion time (Unix ms, empty if failed)"1762339215672"
> bankObjectMandatory-Beneficiary bank information-
>> codeStringMandatory-Bank Swift or Number code (e.g., 002, BRINIDJA)"BRINIDJA"
>> nameStringMandatory-Bank name (e.g., BRI, BNI, DANAMON)"BRI"
>> account_nameStringConditional-Beneficiary account holder name (null if inquiry failed)"Yayasan Wahyudin Tbk"
>> account_numberStringMandatory-Beneficiary bank account number"521398319083210"
> gross_amountObjectMandatory-Total amount including fees-
>> currencyStringMandatory3Currency code (ISO 4217)"IDR"
>> valueStringMandatory-Gross amount value"50000"
> feeObjectMandatory-Transfer fee details-
>> currencyStringMandatory3Currency code (ISO 4217)"IDR"
>> valueStringMandatory-Fee amount"3000"
> net_amountObjectMandatory-Amount received by beneficiary (gross - fee)-
>> currencyStringMandatory3Currency code (ISO 4217)"IDR"
>> valueStringMandatory-Net amount value"47000"
> balance_afterObjectMandatory-Account balance after transaction-
>> currencyStringConditional3Currency code (null if failed)"IDR"
>> valueStringConditional-Balance value (null if failed)"777000"
> notesStringOptional-Transaction notes from request"bayar pajak"
> failed_codeStringConditional-Error code (only present when status is failed)"SP003"
> failed_reasonStringConditional-Error message (only present when status is failed)"INSUFFICIENT BALANCE"

Body Example

Success: Here’s an example of a successful disbursement transaction.

{
    "response_code": "SP000",
    "response_message": "Successfully",
    "event": "disbursement",
    "data": {
        "transaction_id": "101222025122910292195055674",
        "reference_number": "11111111118",
        "transaction_status": {
            "code": "00",
            "desc": "Success"
        },
        "post_timestamp": "1766978961000",
        "processed_timestamp": "1766978962000",
        "bank": {
            "code": "002",
            "name": "BRI",
            "account_name": "Dummy Test Account Internal",
            "account_number": "11111111118"
        },
        "gross_amount": {
            "currency": "IDR",
            "value": "12504.00"
        },
        "fee": {
            "currency": "IDR",
            "value": "2500"
        },
        "net_amount": {
            "currency": "IDR",
            "value": "10004.00"
        },
        "balance_after": {
            "currency": "IDR",
            "value": "829988"
        },
        "notes": "test transfer"
    }
}

Failed: Here’s an example of a failed disbursement transaction.

{
    "response_code": "SP001",
    "response_message": "Transaction Failure",
    "event": "disbursement",
    "data": {
        "transaction_id": "121222025122617513896515436",
        "reference_number": "333",
        "transaction_status": {
            "code": "06",
            "desc": "Failed"
        },
        "post_timestamp": "1766746298000",
        "processed_timestamp": "",
        "bank": {
            "code": "002",
            "name": "BRI",
            "account_name": "",
            "account_number": "091701064838533"
        },
        "gross_amount": {
            "currency": "IDR",
            "value": "12501.00"
        },
        "fee": {
            "currency": "IDR",
            "value": "2500"
        },
        "net_amount": {
            "currency": "IDR",
            "value": "10001.00"
        },
        "balance_after": {
            "currency": "IDR",
            "value": "0"
        },
        "notes": "test transfer",
        "failed_reason": "Transaction Failure : Invalid beneficiary account: Account inactive",
        "failed_code": "SP001"
    }
}

Appendix

01: Response Code

Response CodeResponse MessageDescriptionMerchant Action
SP000SuccessfullySuccessNeed check inquiry status
SP001Transaction FailureFailed TransactionNeed check inquiry status
SP002General FailureInternal Server ErrorNeed check inquiry status
SP003Insufficient BalanceRunning out of account balanceNeed check inquiry status
SP004Duplicate Reference NumberDuplicate Reference Number-
SP005TimeoutGateway timeoutNeed check inquiry status
SP006Exceed Beneficiary LimitBeneficiary has exceeded the amount transaction limitNeed check inquiry status
SP007Exceed Account LimitYour account reached transaction limitPlease contact IT Support
SP008Invalid Reference NumberReference Number does not exists-
SP009Transaction Not FoundTransaction Not Found-
SP010Beneficiary Account Not FoundBeneficiary Account Not Found-
SP011Beneficiary Vendor Not ActiveBeneficiary Vendor Not Active-
SP012Bad RequestBad Request-
SP013UnauthorizedUnauthorized-
SP014Not FoundNot Found-
SP015ForbiddenForbidden-
SP016Signature InvalidSignature Invalid-
SP017Unauthorized IPYour IP Not AuthorizedPlease contact IT Support
SP018Validation errorPayload request validation error-
SP019General ErrorGeneral ErrorPlease contact IT Support
SP020Merchant Account Not FoundMerchant Account Not Found-

02: Transaction Status

CodeDescNotes
00SuccessSucceed
01InitiatedPayment call has not been received, and payment retry is possible
02PayingPending/Suspect:
- can retry to the check status after next few minutes
- Waiting callback for final status
03PendingPending/Suspect:
- can retry to the check status after next few minutes
- Waiting callback for final status
04RefundedReversal transaction
05CanceledPlease create new transaction
06FailedFailed Transaction
07Not FoundPlease create new transaction

Security Mechanisms

Overview

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.

Security Options

Option 1: IP Whitelist (Simpler Approach)

This is the simplest security method where you restrict webhook access to only authorized IP addresses from Singa Payment Gateway.

How it works:

  • Configure your firewall or application to only accept requests from specific IP addresses
  • Singa Payment Gateway will provide you with our official IP addresses
  • Any requests from unauthorized IPs will be automatically rejected

Pros:

  • ✅ Simple to implement
  • ✅ No complex cryptographic operations required
  • ✅ Works well for basic security needs

Cons:

  • ❌ Less secure if IP addresses are compromised
  • ❌ Requires manual updates if our IPs change
  • ❌ Cannot verify request integrity (body tampering)

Implementation Example (Nginx):

location /webhook/disbursement {
    # 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:

  1. The request actually comes from Singa Payment Gateway
  2. The request body has not been tampered with during transmission

How it works:

  • Singa Payment Gateway signs each webhook request using HMAC-SHA512
  • You validate the signature using your Client Secret
  • Only requests with valid signatures are processed

Pros:

  • ✅ Highest security level
  • ✅ Verifies both authenticity and integrity
  • ✅ Protects against man-in-the-middle attacks
  • ✅ No dependency on IP addresses

Cons:

  • ❌ Requires implementation of signature validation logic
  • ❌ Slightly more complex to implement

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:

  1. First layer: Check IP whitelist (fast, blocks unauthorized IPs immediately)
  2. Second layer: Validate signature (ensures request integrity)

Benefits:

  • ✅ Defense in depth - multiple security layers
  • ✅ Protection against both unauthorized access and tampering
  • ✅ Industry best practice for webhook security

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/disbursement';

if (!validateWebhookSignature($requestBody, $headers, $clientSecret, $endpoint)) {
    http_response_code(401);
    exit;
}

// Both checks passed - process webhook
$payload = json_decode($requestBody, true);
// ... process transaction ...
?>

Which Option Should You Choose?

Recommended ApproachReason
IP Whitelist onlySimple to implement, easy to debug, suitable for testing environments
Signature validation onlyHigher security, good for testing signature implementation
IP Whitelist + SignatureMaximum security, industry best practice, recommended for production
IP Whitelist + Signature + Timestamp validationAdditional protection against replay attacks for high-security requirements

Getting Singa Payment Gateway IP Addresses

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.


Overview

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.

Signature Algorithm: HMAC SHA512

The signature is created using a multi-step process that combines the request method, endpoint, access token, hashed body, and timestamp.

Step-by-Step Validation Guide

Step 1: Extract Headers

Extract the following headers from the incoming webhook request:

  • X-Signature - The signature to validate
  • X-Timestamp - Unix timestamp (in seconds)
  • Authorization - Bearer token (extract the token part)

Step 2: Get Your Client Secret

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.

Step 3: Extract Endpoint Path

Extract the endpoint path from your webhook URL. For example:

  • Full URL: https://yourdomain.com/webhook/disbursement?param=value
  • Endpoint: /webhook/disbursement?param=value

The endpoint includes the path and any query parameters.

Step 4: Normalize and Hash the Request Body

The request body must be normalized before hashing to ensure consistent results:

  1. Parse JSON: Decode the JSON body into an object/array
  2. Sort Keys Recursively: Sort all object keys alphabetically at every level
  3. Re-encode JSON: Encode back to JSON with these flags:
    • JSON_UNESCAPED_UNICODE - Don’t escape Unicode characters
    • JSON_UNESCAPED_SLASHES - Don’t escape forward slashes
  4. Hash with SHA-256: Generate SHA-256 hash of the normalized JSON

Example:

Original JSON: {"status":200,"success":true,"data":{"transaction_id":"123"}}
After sorting: {"data":{"transaction_id":"123"},"status":200,"success":true}
SHA-256 Hash: 5f4dcc3b5aa765d61d8327deb882cf99acd3d28e5cf0e661c02c8e8e6e8e6f9a

Step 5: Build the String to Sign

Concatenate the following values with colon (:) as separator:

StringToSign = METHOD + ":" + ENDPOINT + ":" + ACCESS_TOKEN + ":" + HASHED_BODY + ":" + TIMESTAMP

Example:

Method:       POST
Endpoint:     /webhook/disbursement
Access Token: eyJ0eXAiOiJKV1QiLCJhbGci...
Hashed Body:  5f4dcc3b5aa765d61d8327deb882cf99acd3d28e5cf0e661c02c8e8e6e8e6f9a
Timestamp:    1695711945

StringToSign = POST:/webhook/disbursement:eyJ0eXAiOiJKV1QiLCJhbGci...:5f4dcc3b5aa765d61d8327deb882cf99acd3d28e5cf0e661c02c8e8e6e8e6f9a:1695711945

Step 6: Generate HMAC SHA512 Signature

Use your Client Secret as the HMAC key and hash the string to sign:

Calculated Signature = HMAC-SHA512(StringToSign, Client Secret)

Step 7: Compare Signatures

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
}

Implementation Examples

PHP

<?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/disbursement'; // Your webhook endpoint path

if (validateWebhookSignature($requestBody, $headers, $clientSecret, $endpoint)) {
    // Signature is valid - process webhook
    $payload = json_decode($requestBody, true);

    // Process the transaction
    $transactionId = $payload['data']['transaction_id'];
    $referenceNumber = $payload['data']['reference_number'];
    $status = $payload['data']['status'];
    $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']);
}
?>

Python

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/disbursement', methods=['POST'])
def webhook_disbursement():
    """Handle Disbursement 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/disbursement'  # Your webhook endpoint path

    if validate_webhook_signature(request_body, headers, client_secret, endpoint):
        # Signature is valid - process webhook
        payload = json.loads(request_body)

        # Process the transaction
        transaction_id = payload['data']['transaction_id']
        reference_number = payload['data']['reference_number']
        status = payload['data']['status']
        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)

JavaScript (Node.js with Express)

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/disbursement', (req, res) => {
    const requestBody = req.rawBody;
    const headers = req.headers;
    const clientSecret = 'your-client-secret-from-dashboard'; // Get from merchant dashboard
    const endpoint = '/webhook/disbursement'; // Your webhook endpoint path

    if (validateWebhookSignature(requestBody, headers, clientSecret, endpoint)) {
        // Signature is valid - process webhook
        const payload = req.body;

        // Process the transaction
        const transactionId = payload.data.transaction_id;
        const referenceNumber = payload.data.reference_number;
        const status = payload.data.status;
        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');
});

Important Notes

Best Practices

  • Always validate signatures: Never process webhook requests without validating the signature first
  • Use constant-time comparison: Prevents timing attacks (use hash_equals(), crypto.timingSafeEqual(), or hmac.compare_digest())
  • Validate timestamp: Check that the timestamp is recent (within 5 minutes) to prevent replay attacks
  • Preserve raw body: Don’t parse the JSON before validation - use the raw request body for hashing
  • Case sensitivity: The signature is case-sensitive (lowercase hex)
  • Character encoding: Use UTF-8 encoding for all strings
  • HMAC key: Always use your client_secret as the HMAC key
  • Secure storage: Store your Client Secret securely (environment variables, secure vault)
  • HTTPS only: Always use HTTPS for your webhook endpoints

Common Mistakes to Avoid

  • ❌ Not sorting JSON keys before hashing (order matters!)
  • ❌ Using wrong hash algorithm (must use SHA-256 for body, SHA-512 for signature)
  • ❌ Wrong separator in string to sign (must use :, not _ or -)
  • ❌ Using API Key instead of Client Secret
  • ❌ Not preserving raw request body (parsing JSON before validation)
  • ❌ Using simple string comparison instead of constant-time comparison
  • ❌ Wrong order in string to sign (must be: METHOD:ENDPOINT:TOKEN:HASH:TIMESTAMP)
  • ❌ Forgetting to extract Bearer token from Authorization header
  • ❌ Not including query parameters in endpoint path
  • ❌ Processing webhook without validating signature first

Troubleshooting

If signature validation fails:

  1. Check string to sign format: Must be METHOD:ENDPOINT:ACCESS_TOKEN:HASHED_BODY:TIMESTAMP
  2. Verify JSON normalization: Keys must be sorted recursively, use correct JSON encoding flags
  3. Check hash algorithm: SHA-256 for body hash, SHA-512 for HMAC signature
  4. Verify HMAC key: Must use Client Secret (not API Key)
  5. Check endpoint path: Must match exactly including query parameters
  6. Verify timestamp: Should be a valid Unix timestamp in seconds
  7. Check access token: Extract correctly from Authorization header (remove “Bearer ” prefix)
  8. Log values for debugging: Log stringToSign, hashedBody, and calculatedSignature (in development only)

Security Considerations

  • Prevent replay attacks: Validate that X-Timestamp is recent (within 5 minutes of current time)
  • Use HTTPS: Always use HTTPS for webhook endpoints to prevent man-in-the-middle attacks
  • Rate limiting: Implement rate limiting on webhook endpoints
  • IP whitelisting: Consider whitelisting Singa Payment Gateway IP addresses
  • Error handling: Don’t expose detailed error messages to webhook sender
  • Idempotency: Handle duplicate webhooks gracefully (use reference_number to detect duplicates)
  • Logging: Log all webhook requests (with signatures removed) for audit purposes

Response Requirements

Your webhook endpoint must return an appropriate HTTP response:

Success Response

When the webhook is processed successfully:

Status Code: 200 OK

{
  "status": "success"
}

Error Responses

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.

Disbursement Specific Notes

Idempotency

Disbursements use reference_number for idempotency:

  • Each reference_number can only be used once per account
  • If you retry with the same reference_number, you’ll receive the existing transaction status
  • This prevents duplicate transfers
  • Always use unique reference numbers for new transactions

Example - Detecting duplicate webhook:

$referenceNumber = $payload['data']['reference_number'];

// Check if already processed
$existingTransaction = findTransactionByReference($referenceNumber);
if ($existingTransaction) {
    // Already processed, return success without re-processing
    return http_response_code(200);
}

// Process new transaction
processNewDisbursement($payload);

Timestamp Format

Important: Disbursement webhooks use two different timestamp formats:

  1. X-Timestamp header (for signature validation): Unix timestamp in seconds

    • Generated using: floor(microtime(true))
    • Example: 1695711945
    • Used for signature generation and validation
  2. post_timestamp & processed_timestamp (in request body): Unix timestamp in milliseconds

    • Generated using: Carbon::parse()->getPreciseTimestamp(3)
    • Example: 1762339215000
    • Used for displaying transaction timing

Converting millisecond timestamps:

// Convert to DateTime
$postTimestamp = $payload['data']['post_timestamp']; // "1762339215000"
$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).

Balance Tracking

The balance_after field shows your account balance after the transaction:

  • For successful transactions: Shows remaining balance after deduction
  • For failed transactions: Will be null (no balance change)
  • Use this to verify balance consistency with your system
  • All amounts are in IDR (Indonesian Rupiah)

Example - Reconciliation:

$payload = json_decode($requestBody, true);
$status = $payload['data']['status'];

if ($status === 'success') {
    $balanceAfter = (float)$payload['data']['balance_after']['value'];
    $grossAmount = (float)$payload['data']['gross_amount']['value'];

    // Verify balance consistency
    $expectedBalance = $previousBalance - $grossAmount;
    if (abs($balanceAfter - $expectedBalance) > 0.01) {
        // Balance mismatch - log for investigation
        logBalanceDiscrepancy($transactionId, $expectedBalance, $balanceAfter);
    }
}