> ## Documentation Index
> Fetch the complete documentation index at: https://docs.singapay.id/llms.txt
> Use this file to discover all available pages before exploring further.

# Security and Signature Validation

> Verify SingaPay webhook authenticity using IP whitelisting and HMAC-SHA512 signature validation. Applies to all webhook types.

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](/api-reference/webhooks/retry-mechanism).

<CodeGroup>
  ```json 200 OK — Success theme={null}
  {
    "status": "success"
  }
  ```

  ```json 401 Unauthorized — Invalid Signature theme={null}
  {
    "status": "error",
    "message": "Invalid signature"
  }
  ```

  ```json 500 Internal Server Error — Processing Error theme={null}
  {
    "status": "error",
    "message": "Failed to process webhook"
  }
  ```
</CodeGroup>

<Tip>
  Process webhooks asynchronously — return `200 OK` immediately and handle the payload in a background job to avoid timeouts.
</Tip>

***

## Security mechanisms

<CardGroup cols={3}>
  <Card title="IP Whitelist" icon="shield-halved">
    Simplest approach. Restrict access to SingaPay's official IP addresses at the firewall or app level.
  </Card>

  <Card title="Signature Validation" icon="key">
    Recommended. Cryptographically verify every request is authentic and untampered using HMAC-SHA512.
  </Card>

  <Card title="Combined (Recommended)" icon="shield-check">
    Maximum security. Layer IP whitelisting first (fast rejection), then validate signatures (integrity).
  </Card>
</CardGroup>

| Approach                              | Best For                                                 |
| ------------------------------------- | -------------------------------------------------------- |
| IP Whitelist only                     | Simple setup, testing environments                       |
| Signature only                        | Higher security, good for testing signature logic        |
| **IP Whitelist + Signature**          | **Production — industry best practice**                  |
| IP + Signature + Timestamp validation | High-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.

<CodeGroup>
  ```nginx Nginx theme={null}
  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;
  }
  ```

  ```php PHP theme={null}
  <?php
  $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...
  ?>
  ```
</CodeGroup>

### Option 2: Signature Validation

See [How to validate signature](#how-to-validate-signature) below.

### Option 3: Combined (maximum security)

```php PHP theme={null}
<?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

<Note>
  Signature validation is **optional** but **strongly recommended** for all production environments.
</Note>

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

<Steps>
  <Step title="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
  </Step>

  <Step title="Get Your Client Secret">
    Retrieve your **Client Secret** from the merchant dashboard. This is your HMAC key.

    <Warning>
      Never expose your Client Secret in client-side code or logs. Store it in environment variables or a secure vault.
    </Warning>
  </Step>

  <Step title="Extract Endpoint Path">
    Include the full path with any query parameters.

    ```text theme={null}
    Full URL:  https://yourdomain.com/webhook/callback?param=value
    Endpoint:  /webhook/callback?param=value
    ```
  </Step>

  <Step title="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**

    ```text theme={null}
    Original:  {"status":200,"success":true,"data":{"transaction":{"reff_no":"123"}}}
    Sorted:    {"data":{"transaction":{"reff_no":"123"}},"status":200,"success":true}
    SHA-256:   5f4dcc3b5aa765d61d8327deb882cf99...
    ```
  </Step>

  <Step title="Build the String to Sign">
    Concatenate with `:` as the separator:

    ```text theme={null}
    StringToSign = METHOD:ENDPOINT:ACCESS_TOKEN:HASHED_BODY:TIMESTAMP

    Example:
    POST:/webhook/callback:a1b2c3d4e5f6...:5f4dcc3b...:1695711945
    ```
  </Step>

  <Step title="Generate the HMAC-SHA512 Signature">
    ```text theme={null}
    Calculated Signature = HMAC-SHA512(StringToSign, Client Secret)
    ```
  </Step>

  <Step title="Compare Signatures (Constant-Time)">
    | Language | Function                   |
    | -------- | -------------------------- |
    | PHP      | `hash_equals()`            |
    | Node.js  | `crypto.timingSafeEqual()` |
    | Python   | `hmac.compare_digest()`    |
  </Step>
</Steps>

### Implementation examples

<CodeGroup>
  ```php PHP theme={null}
  <?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']);
  }
  ?>
  ```

  ```python Python (Flask) theme={null}
  import hmac, hashlib, json, os
  from flask import Flask, request, jsonify

  app = Flask(__name__)

  def sort_dict_recursive(data):
      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(i) for i in data]
      return data

  def validate_webhook_signature(request_body, headers, client_secret, endpoint):
      received_signature = headers.get('X-Signature', '')
      timestamp          = headers.get('X-Timestamp', '')
      access_token       = headers.get('Authorization', '').replace('Bearer ', '')

      try:
          body_dict = json.loads(request_body)
      except json.JSONDecodeError:
          return False

      sorted_body     = sort_dict_recursive(body_dict)
      normalized_json = json.dumps(sorted_body, ensure_ascii=False, separators=(',', ':'))
      hashed_body     = hashlib.sha256(normalized_json.encode('utf-8')).hexdigest()

      string_to_sign = f"POST:{endpoint}:{access_token}:{hashed_body}:{timestamp}"
      calculated_sig  = hmac.new(
          client_secret.encode('utf-8'),
          string_to_sign.encode('utf-8'),
          hashlib.sha512
      ).hexdigest()

      return hmac.compare_digest(calculated_sig, received_signature)

  @app.route('/webhook/callback', methods=['POST'])
  def webhook_callback():
      request_body  = request.get_data(as_text=True)
      client_secret = os.environ['SINGAPAY_CLIENT_SECRET']
      endpoint      = '/webhook/callback'

      if validate_webhook_signature(request_body, dict(request.headers), client_secret, endpoint):
          return jsonify({'status': 'success'}), 200
      return jsonify({'status': 'error', 'message': 'Invalid signature'}), 401
  ```

  ```javascript Node.js (Express) theme={null}
  const express = require('express');
  const crypto = require('crypto');

  const app = express();

  app.use(express.json({
      verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); }
  }));

  function sortObjectRecursive(obj) {
      if (typeof obj !== 'object' || obj === null) return obj;
      if (Array.isArray(obj)) return obj.map(sortObjectRecursive);
      return Object.keys(obj).sort().reduce((sorted, key) => {
          sorted[key] = sortObjectRecursive(obj[key]);
          return sorted;
      }, {});
  }

  function validateWebhookSignature(requestBody, headers, clientSecret, endpoint) {
      const receivedSignature = headers['x-signature'] || '';
      const timestamp         = headers['x-timestamp'] || '';
      const accessToken       = (headers['authorization'] || '').replace('Bearer ', '');

      let bodyObject;
      try { bodyObject = JSON.parse(requestBody); }
      catch (e) { return false; }

      const sortedBody     = sortObjectRecursive(bodyObject);
      const normalizedJson = JSON.stringify(sortedBody);
      const hashedBody     = crypto.createHash('sha256').update(normalizedJson).digest('hex');
      const stringToSign   = `POST:${endpoint}:${accessToken}:${hashedBody}:${timestamp}`;
      const calculatedSig  = crypto.createHmac('sha512', clientSecret).update(stringToSign).digest('hex');

      try {
          return crypto.timingSafeEqual(
              Buffer.from(calculatedSig),
              Buffer.from(receivedSignature)
          );
      } catch {
          return false;
      }
  }

  app.post('/webhook/callback', (req, res) => {
      const clientSecret = process.env.SINGAPAY_CLIENT_SECRET;
      const endpoint     = '/webhook/callback';

      if (validateWebhookSignature(req.rawBody, req.headers, clientSecret, endpoint)) {
          res.status(200).json({ status: 'success' });
      } else {
          res.status(401).json({ status: 'error', message: 'Invalid signature' });
      }
  });

  app.listen(3000);
  ```
</CodeGroup>

### Best practices and troubleshooting

<AccordionGroup>
  <Accordion title="Do's" icon="circle-check" iconType="solid">
    * 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
  </Accordion>

  <Accordion title="Common mistakes" icon="circle-xmark" iconType="solid">
    * 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
  </Accordion>

  <Accordion title="Troubleshooting" icon="wrench">
    If signature validation fails, check in order:

    1. **String to sign format** — `POST: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)
  </Accordion>
</AccordionGroup>

***

## 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).
