> For the complete documentation index, see [llms.txt](https://docs.mychips.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.mychips.io/reward-handling/validating-the-webhook-s2s.md).

# Validating the Webhook S2S

You can validate incoming LoyaltyPlay postbacks using two methods: **source IP validation** and **HMAC-SHA256 signature verification**. The sections below explain how to configure and use each method.

## Validating Postbacks with an IP Whitelist

To validate a Webhook, verify the IP it's coming from in the HTTP header.

Here it is the list of IPs from where we send the Webhooks S2S to publishers. Use these IPs to avoid security issues and validate incoming postbacks.

```
168.63.37.145 
20.54.96.37 
13.70.194.104 
34.146.139.91
34.54.234.115 
34.54.248.253  
34.64.93.62   
34.47.93.43	     
34.84.180.208	 
48.209.163.104  
4.207.193.125    
48.209.162.122
34.140.72.20
```

#### **Securing X-Forwarded-For Header**

When your service is behind a load balancer or reverse proxy, be aware of potential manipulation of the `X-Forwarded-For` header. This header is used to identify the originating IP address of the client connecting to the web server through an HTTP proxy or load balancer.

**Risks:**

* **Header Manipulation**: Attackers can spoof the `X-Forwarded-For` header to bypass IP restrictions.

**Security Measures:**

* **Trusting Proxies**: Only trust headers from known proxies or load balancers. Each cloud platform adds the client IP address at a specific position in the `X-Forwarded-For` chain, which you should consider when validating the IP.
  * **AWS (ELB/ALB)**: AWS puts the true client IP at the beginning of the `X-Forwarded-For` list.
  * **Google Cloud Platform (GCP)**: GCP adds the original client IP at the second-to-last position.
  * **Azure**: Azure load balancers append the real client IP at the last position.
* Make sure to parse this header correctly depending on your cloud provider to avoid accepting a spoofed IP.

**PHP Code Example for AWS:**

```php
// Function to get the real client IP when behind AWS ELB/ALB
function get_client_ip() {
    // Check if X-Forwarded-For header exists
    if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
        // Split the X-Forwarded-For header into an array
        $forwarded_ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
        
        // The first IP in the list is the real client IP (AWS specific)
        $client_ip = trim($forwarded_ips[0]);
    } else {
        // Fallback to REMOTE_ADDR if X-Forwarded-For is not present
        $client_ip = $_SERVER['REMOTE_ADDR'];
    }

    return $client_ip;
}
```

## Validating Postbacks with HMAC-SHA256 Signatures

Add the `{signature}` macro to your postback URL and every postback we send you will carry an\
HMAC-SHA256 signature of its query parameters, computed with a secret only you and we know.\
Verifying it lets you reject postbacks that did not come from us or that were changed on the way.

The feature is opt-in: postback URLs without `{signature}` keep working exactly as today.

***

#### 1. Setup in the publisher dashboard

**Step 1 — Generate your signing secret**

1. Open the publisher dashboard and go to **Settings** page.
2. Below your API key, find the **Signing secret** section.
3. Click **Generate secret**.
4. Click **Copy** to copy your signing secret (64 hexadecimal characters), then store it securely on your server, for example in a secrets manager or an environment variable. You can return to this page to copy it again at any time.

Good to know:

* **One signing secret per account.** The same secret is used to sign postbacks for both your global postback URL and each ad unit’s postback URL.
* **Generate it only once.** The secret cannot be regenerated or rotated from the dashboard. If you suspect it has been exposed, contact your account manager.
* **Keep it confidential.** Treat the secret like a password. Never include it in client-side code, URLs, or logs.

**Step 2 — Add `{signature}` to your postback URL**

1. On the **Settings** page, select the global postback or the ad unit whose postbacks you want to sign.
2. Add a query parameter with `{signature}` as its entire value. You can choose any parameter name; the examples below use `sig`.
3. Save your updated postback URL.

Example:

```
https://your-server.com/postback?click_id={click_id}&payout={payout}&sig={signature}
```

The macros other than `{signature}` are examples only. Use the macros listed in the **Publisher macros** table on the **Settings** page.

Rules for the macro:

| Rule                                                  | Example                 |
| ----------------------------------------------------- | ----------------------- |
| ✅ It must be the **whole value** of a query parameter | `&sig={signature}`      |
| ❌ It cannot be part of a longer value                 | `&sig=v1_{signature}`   |
| ❌ It cannot be in the path or the fragment            | `/postback/{signature}` |
| ✅ The encoded form is accepted too                    | `&sig=%7Bsignature%7D`  |

> ⚠️ **Generate the secret before adding the macro.** If the URL contains `{signature}` but no\
> secret exists yet, the postback is sent with the literal text `{signature}` (or `%7Bsignature%7D`)\
> instead of a signature.
>
> ⚠️ **Respect the placement rule.** If `{signature}` is not the whole value of a query parameter,\
> the postback cannot be signed correctly and **is not delivered**.

***

#### 2. Verification on your server

**What is signed**

The signature covers **only the query string** of the postback URL, excluding the signature\
parameter itself. Scheme, host, path and (for POST postbacks) the request body are **not** signed.

**Algorithm**

Given the request you received:

1. **Take the raw query string, exactly as received.** Everything after `?`.\
   **Do not URL-decode it**: values are signed in their percent-encoded form, as they travel on\
   the wire (e.g. `Mozilla%2F5.0`, not `Mozilla/5.0`). Most frameworks decode query parameters\
   automatically, so read the raw string (see the snippets below).
2. **Split it on `&`** into `key=value` pairs, ignoring empty segments. Split each pair on the\
   **first** `=`. A parameter without `=` has an empty value (`flag` becomes `flag=`).
3. **Remove the signature parameter** (`sig` in our example) and keep its value aside: that is\
   the signature you received.
4. **Sort the remaining pairs by key, then by value**, using a plain byte-wise (ordinal),\
   case-sensitive comparison. Uppercase letters sort before lowercase (`B` < `_` < `a`).\
   Repeated keys are all kept.
5. **Join them** as `key=value` with `&`. This is the *canonical string*.
6. **Compute** `HMAC-SHA256(key = secret, message = canonical string)`:
   * use the secret **as text** (its UTF-8 bytes) — do **not** hex-decode it;
   * encode the result as **lowercase hexadecimal** (64 characters).
7. **Compare** it with the received signature using a **constant-time** comparison.\
   Accept the postback only if they match.

**Worked example**

Secret: `my-test-secret`

Received request:

```
GET /cb?uid=u42&pid=abc123&ts=1758067200&amt=1.50&sig=66cfb453f06e3427fccb907be8150dbe2762585560bf0373fce8b43bfa05017b
```

| Step                               | Result                                                               |
| ---------------------------------- | -------------------------------------------------------------------- |
| Pairs without `sig`                | `uid=u42`, `pid=abc123`, `ts=1758067200`, `amt=1.50`                 |
| Sorted & joined (canonical string) | `amt=1.50&pid=abc123&ts=1758067200&uid=u42`                          |
| HMAC-SHA256, lowercase hex         | `66cfb453f06e3427fccb907be8150dbe2762585560bf0373fce8b43bfa05017b` ✅ |

Example with encoded values (same secret) — note that nothing is decoded:

|                  |                                                                    |
| ---------------- | ------------------------------------------------------------------ |
| Received query   | `ua=Mozilla%2F5.0%20%28X%29&name=a%26b&sig=…`                      |
| Canonical string | `name=a%26b&ua=Mozilla%2F5.0%20%28X%29`                            |
| Signature        | `0f73f47356b7bad9f2bb5167cb59945f1702caed58e26986f2746a175f68d4c5` |

Changing anything in the signed parameters (even `amt=1.50` → `amt=1.5`) produces a different\
signature. Use these two examples as test cases for your implementation.

#### **Reference implementations**

Use the example below for your server-side language. Always verify the raw query string without URL-decoding it.

{% tabs %}
{% tab title="Node.js" %}

```javascript
// Express: pass req.originalUrl to verifySignature().
const crypto = require('crypto');

function verifySignature(rawUrl, secret, signatureParam = 'sig') {
  const query = (rawUrl.split('#')[0].split('?')[1]) || '';
  let received = null;
  const pairs = [];
  for (const part of query.split('&')) {
    if (!part) continue;
    const i = part.indexOf('=');
    const key = i < 0 ? part : part.slice(0, i);
    const value = i < 0 ? '' : part.slice(i + 1);
    if (key === signatureParam) { received = value.toLowerCase(); continue; }
    pairs.push([key, value]);
  }
  if (!received) return false;

  pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
  const canonical = pairs.map(([k, v]) => `${k}=${v}`).join('&');
  const expected = crypto.createHmac('sha256', secret).update(canonical, 'utf8').digest('hex');

  return expected.length === received.length
    && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}
```

{% endtab %}

{% tab title="Python" %}

```python
# Flask: pass request.query_string.decode() to verify_signature().
# Django: pass request.META["QUERY_STRING"] to verify_signature().
import hashlib
import hmac


def verify_signature(raw_query: str, secret: str, signature_param: str = "sig") -> bool:
    received = None
    pairs = []
    for part in raw_query.split("&"):
        if not part:
            continue
        key, _, value = part.partition("=")
        if key == signature_param:
            received = value.lower()
            continue
        pairs.append((key, value))
    if not received:
        return False

    canonical = "&".join(f"{k}={v}" for k, v in sorted(pairs))
    expected = hmac.new(secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, received)
```

{% endtab %}

{% tab title="PHP" %}

```php
// Pass $_SERVER['QUERY_STRING'] to verifySignature().
// Do not use $_GET: its values are already URL-decoded.
function verifySignature(string $rawQuery, string $secret, string $signatureParam = 'sig'): bool
{
    $received = null;
    $pairs = [];
    foreach (explode('&', $rawQuery) as $part) {
        if ($part === '') continue;
        $kv = explode('=', $part, 2);
        if ($kv[0] === $signatureParam) { $received = strtolower($kv[1] ?? ''); continue; }
        $pairs[] = [$kv[0], $kv[1] ?? ''];
    }
    if (!$received) return false;

    usort($pairs, fn($a, $b) => strcmp($a[0], $b[0]) ?: strcmp($a[1], $b[1]));
    $canonical = implode('&', array_map(fn($p) => $p[0] . '=' . $p[1], $pairs));
    $expected = hash_hmac('sha256', $canonical, $secret);

    return hash_equals($expected, $received);
}
```

{% endtab %}
{% endtabs %}

***

#### 3. Troubleshooting

| Symptom                                                   | Likely cause                                                                                                                                                                                               |
| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| You receive the literal `{signature}` / `%7Bsignature%7D` | The signing secret has not been generated yet (step 1).                                                                                                                                                    |
| Postbacks stopped arriving after adding the macro         | `{signature}` is not the whole value of a query parameter.                                                                                                                                                 |
| Signature never matches                                   | Query values were URL-decoded before signing; the secret was hex-decoded instead of used as text; pairs were sorted case-insensitively; the signature parameter was not removed from the canonical string. |
| Signature matches only for some postbacks                 | Values with special characters (spaces, `/`, `&`, …) are being decoded or re-encoded before verification.                                                                                                  |
