Skip to main content
All SingaPay webhooks use the same security model. Validate every incoming POST before processing the payload.

Acknowledging webhooks

Your endpoint must return an HTTP 2xx response to acknowledge receipt. Return the response quickly and process the payload in a background job when possible. If your endpoint does not return a valid 2xx response, SingaPay retries delivery according to the rules in Webhook retry mechanism.
{
  "status": "success"
}
Process webhooks asynchronously — return 200 OK immediately and handle the payload in a background job to avoid timeouts.

Security mechanisms

IP Whitelist

Simplest approach. Restrict access to SingaPay’s official IP addresses at the firewall or app level.

Signature Validation

Recommended. Cryptographically verify every request is authentic and untampered using HMAC-SHA512.

Combined (Recommended)

Maximum security. Layer IP whitelisting first (fast rejection), then validate signatures (integrity).
ApproachBest For
IP Whitelist onlySimple setup, testing environments
Signature onlyHigher security, good for testing signature logic
IP Whitelist + SignatureProduction — industry best practice
IP + Signature + Timestamp validationHigh-security requirements with replay attack protection

Option 1: IP Whitelist

Configure your firewall or application to only accept requests from SingaPay’s official IP addresses. Contact support or check your merchant dashboard for the official IP list.
location /webhook/callback {
    allow 103.xxx.xxx.xxx;  # Replace with actual IPs from SingaPay
    allow 103.xxx.xxx.xxx;
    deny all;
    proxy_pass http://your-backend;
}

Option 2: Signature Validation

See How to validate signature below.

Option 3: Combined (maximum security)

PHP
<?php
// Layer 1: IP Whitelist (fast rejection)
$allowedIPs = ['103.xxx.xxx.xxx', '103.xxx.xxx.xxx'];
if (!in_array($_SERVER['REMOTE_ADDR'], $allowedIPs)) {
    http_response_code(403);
    exit;
}

// Layer 2: Signature Validation (integrity check)
$requestBody = file_get_contents('php://input');
$headers = getallheaders();
$endpoint = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$query = $_SERVER['QUERY_STRING'] ?? '';
if ($query !== '') {
    $endpoint .= '?' . $query;
}

if (!validateWebhookSignature($requestBody, $headers, 'your-client-secret', $endpoint)) {
    http_response_code(401);
    exit;
}

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

How to validate signature

Signature validation is optional but strongly recommended for all production environments.
The X-Signature header is an HMAC-SHA512 signature generated by SingaPay. The ENDPOINT value in the string to sign must match the path and query string of your configured webhook URL exactly (for example /webhook/callback or /api/v1/webhooks/singapay).

Steps

1

Extract Headers

Pull these three values from the incoming request:
  • X-Signature — the signature to verify
  • X-Timestamp — Unix timestamp in seconds
  • Authorization — strip "Bearer " prefix to get the raw token
2

Get Your Client Secret

Retrieve your Client Secret from the merchant dashboard. This is your HMAC key.
Never expose your Client Secret in client-side code or logs. Store it in environment variables or a secure vault.
3

Extract Endpoint Path

Include the full path with any query parameters.
Full URL:  https://yourdomain.com/webhook/callback?param=value
Endpoint:  /webhook/callback?param=value
4

Normalize and Hash the Request Body

  1. Parse the raw JSON body
  2. Sort all object keys recursively and alphabetically
  3. Re-encode with JSON_UNESCAPED_UNICODE and JSON_UNESCAPED_SLASHES
  4. Hash with SHA-256
Original:  {"status":200,"success":true,"data":{"transaction":{"reff_no":"123"}}}
Sorted:    {"data":{"transaction":{"reff_no":"123"}},"status":200,"success":true}
SHA-256:   5f4dcc3b5aa765d61d8327deb882cf99...
5

Build the String to Sign

Concatenate with : as the separator:
StringToSign = METHOD:ENDPOINT:ACCESS_TOKEN:HASHED_BODY:TIMESTAMP

Example:
POST:/webhook/callback:a1b2c3d4e5f6...:5f4dcc3b...:1695711945
6

Generate the HMAC-SHA512 Signature

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

Compare Signatures (Constant-Time)

LanguageFunction
PHPhash_equals()
Node.jscrypto.timingSafeEqual()
Pythonhmac.compare_digest()

Implementation examples

<?php
function validateWebhookSignature($requestBody, $headers, $clientSecret, $endpoint) {
    $receivedSignature = $headers['X-Signature'] ?? '';
    $timestamp         = $headers['X-Timestamp'] ?? '';
    $accessToken       = str_replace('Bearer ', '', $headers['Authorization'] ?? '');

    $bodyArray = json_decode($requestBody, true);
    if (json_last_error() !== JSON_ERROR_NONE) return false;

    function sortRecursive(&$array) {
        ksort($array, SORT_STRING);
        foreach ($array as &$value) {
            if (is_array($value)) sortRecursive($value);
        }
    }
    sortRecursive($bodyArray);

    $normalizedJson = json_encode($bodyArray, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    $hashedBody     = hash('sha256', $normalizedJson);

    $stringToSign        = "POST:{$endpoint}:{$accessToken}:{$hashedBody}:{$timestamp}";
    $calculatedSignature = hash_hmac('sha512', $stringToSign, $clientSecret);

    return hash_equals($calculatedSignature, $receivedSignature);
}

$requestBody  = file_get_contents('php://input');
$headers      = getallheaders();
$clientSecret = getenv('SINGAPAY_CLIENT_SECRET');
$endpoint     = '/webhook/callback'; // Must match your configured URL path

if (validateWebhookSignature($requestBody, $headers, $clientSecret, $endpoint)) {
    $payload = json_decode($requestBody, true);
    http_response_code(200);
    echo json_encode(['status' => 'success']);
} else {
    http_response_code(401);
    echo json_encode(['status' => 'error', 'message' => 'Invalid signature']);
}
?>

Best practices and troubleshooting

  • Always validate signatures before processing any webhook
  • Use constant-time comparison (hash_equals, timingSafeEqual, compare_digest) to prevent timing attacks
  • Validate X-Timestamp is within 5 minutes of current time to block replay attacks
  • Use the raw request body for hashing — do not parse JSON first
  • Store your Client Secret in environment variables or a secure vault
  • Always use HTTPS for webhook endpoints
  • Handle duplicate webhooks gracefully using stable IDs from the payload as idempotency keys
  • Log all webhook requests (with signatures redacted) for audit purposes
  • Not sorting JSON keys before hashing (order matters)
  • Using SHA-256 for the HMAC instead of SHA-512 (body = SHA-256; signature = SHA-512)
  • Wrong separator in string to sign (must be :, not _ or -)
  • Using your API Key instead of your Client Secret
  • Parsing JSON before hashing (always use the raw body)
  • Using simple string comparison instead of constant-time comparison
  • Wrong field order — must be METHOD:ENDPOINT:TOKEN:HASH:TIMESTAMP
  • Forgetting to strip "Bearer " from the Authorization header
  • Not including query parameters in the endpoint path
If signature validation fails, check in order:
  1. String to sign formatPOST:ENDPOINT:ACCESS_TOKEN:HASHED_BODY:TIMESTAMP
  2. JSON normalization — keys sorted recursively, correct encoding flags
  3. Hash algorithms — SHA-256 for body, SHA-512 for HMAC
  4. HMAC key — Client Secret, not API Key
  5. Endpoint path — exact match including query parameters
  6. Timestamp — valid Unix timestamp in seconds
  7. Access token — extracted from Authorization header, no "Bearer " prefix
  8. Debug logging — log stringToSign, hashedBody, calculatedSignature (dev only)

Idempotency

SingaPay may deliver the same webhook more than once if your endpoint returned a non-2xx response and a retry was scheduled. Use a stable identifier from the payload (such as transaction_id, reff_no, or bill_number) to detect duplicates and skip reprocessing. Return 200 OK only after you have safely recorded the event (or confirmed it was already processed).