# Verifying the Signature

## Verifying the Signature

Each webhook delivery includes a set of signature headers. Your endpoint should use these to authenticate every request before processing it.

{% hint style="warning" %}
If a request fails signature verification, reject it immediately. This prevents untrusted sources from injecting data into your webhook endpoint.
{% endhint %}

***

### Signature Headers

| Header                          | Description                                           | Example           |
| ------------------------------- | ----------------------------------------------------- | ----------------- |
| `X-Webhook-Signature`           | HMAC-SHA256 hex digest to verify against              | `2603ad057b…`     |
| `X-Webhook-Signature-Algorithm` | Algorithm used to sign the request                    | `hmac-sha256`     |
| `X-Webhook-Timestamp`           | Unix timestamp of when the request was sent (seconds) | `1709467498`      |
| `X-Webhook-Request-Id`          | Unique UUID for this delivery                         | `8aaaabcd-0f85-…` |
| `X-Webhook-Signature-Version`   | Key rotation version                                  | `1`               |

***

### Verification Steps

1. Extract `X-Webhook-Signature`, `X-Webhook-Timestamp`, and `X-Webhook-Request-Id` from the request headers.
2. **Reject the request** if the timestamp is older than 5 minutes — this protects against replay attacks.
3. Read the **raw request body** exactly as received. Do not re-serialize or re-encode it.
4. Build the canonical request string (see format below).
5. Compute HMAC-SHA256 using your secret key with the `whsec_` prefix stripped.
6. Compare your result against `X-Webhook-Signature` using a **constant-time comparison** function.

***

### Canonical Request Format

Construct the string by joining the following lines with `\n`. There is no trailing newline.

```
{METHOD}
{host_length}:{host}
{path_length}:{path}
{sha256_hex_of_body}
{timestamp}
{requestId}
```

**Example** — `POST` to `https://example.com/webhooks`:

```
POST
11:example.com
9:/webhooks
860b064dd965fedd0063926f48cdaf7a1c16f40fbe96d96066027a913d46907e
1709467498
8aaaabcd-0f85-46b6-bec3-e343b2f71037
```

***

### Key Handling

Your secret key has the format `whsec_<64 hex chars>`. **Strip the `whsec_` prefix** before passing it to your HMAC function. The remaining hex string is used as-is (ASCII bytes) — do **not** hex-decode it to binary.

***

### Edge Cases

| Scenario                              | Behavior                                                                                    |
| ------------------------------------- | ------------------------------------------------------------------------------------------- |
| Port in URL (`host:8443`)             | Port is **not** included in the host component                                              |
| No path (`https://example.com`)       | Defaults to `/`                                                                             |
| Trailing slash (`/webhooks/`)         | Preserved as-is                                                                             |
| URL-encoded characters (`/abc%20def`) | Used as-is (encoded form)                                                                   |
| Query strings (`?foo=bar`)            | **Not** included                                                                            |
| Empty body                            | SHA-256 of empty string: `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` |

***

### Code Examples

#### **PHP**

```php
function verifyWebhookSignature(
    string $rawBody,
    string $secret,
    string $signatureHeader,
    string $timestampHeader,
    string $requestIdHeader,
    string $url,
    string $method = 'POST',
    int $toleranceSeconds = 300
): bool {
    $timestamp = (int) $timestampHeader;
    if (abs(time() - $timestamp) > $toleranceSeconds) {
        return false;
    }

    $key = str_starts_with($secret, 'whsec_') ? substr($secret, 6) : $secret;

    $parsed = parse_url($url);
    $host = $parsed['host'] ?? '';
    $path = $parsed['path'] ?? '/';

    $bodyHash = hash('sha256', $rawBody);
    $canonicalRequest = implode("\n", [
        strtoupper($method),
        strlen($host) . ':' . $host,
        strlen($path) . ':' . $path,
        $bodyHash,
        $timestampHeader,
        $requestIdHeader,
    ]);

    $expectedSignature = hash_hmac('sha256', $canonicalRequest, $key);

    return hash_equals($expectedSignature, $signatureHeader);
}
```

#### **Python**

```python
import hashlib
import hmac
import time
from urllib.parse import urlparse

def verify_webhook_signature(
    raw_body: str,
    secret: str,
    signature_header: str,
    timestamp_header: str,
    request_id_header: str,
    url: str,
    method: str = "POST",
    tolerance_seconds: int = 300,
) -> bool:
    timestamp = int(timestamp_header)
    if abs(time.time() - timestamp) > tolerance_seconds:
        return False

    key = secret[6:] if secret.startswith("whsec_") else secret

    parsed = urlparse(url)
    host = parsed.hostname or ""
    path = parsed.path or "/"

    body_hash = hashlib.sha256(raw_body.encode("utf-8")).hexdigest()
    canonical_request = "\n".join([
        method.upper(),
        f"{len(host)}:{host}",
        f"{len(path)}:{path}",
        body_hash,
        timestamp_header,
        request_id_header,
    ])

    expected = hmac.new(
        key.encode("utf-8"),
        canonical_request.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature_header)
```

#### **Node.js**

```javascript
const crypto = require('crypto');

function verifyWebhookSignature({
  rawBody,
  secret,
  signatureHeader,
  timestampHeader,
  requestIdHeader,
  webhookUrl,
  method = 'POST',
  toleranceSeconds = 300,
}) {
  const timestamp = parseInt(timestampHeader, 10);
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
    return false;
  }

  const key = secret.startsWith('whsec_') ? secret.slice(6) : secret;

  const parsed = new URL(webhookUrl);
  const host = parsed.hostname;
  const path = parsed.pathname || '/';

  const bodyHash = crypto.createHash('sha256').update(rawBody, 'utf8').digest('hex');
  const canonicalRequest = [
    method.toUpperCase(),
    `${host.length}:${host}`,
    `${path.length}:${path}`,
    bodyHash,
    timestampHeader,
    requestIdHeader,
  ].join('\n');

  const expected = crypto.createHmac('sha256', key).update(canonicalRequest, 'utf8').digest('hex');

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
```

***

### Common Pitfalls

* **Re-serializing the body** — JSON serialization is not deterministic across languages. Always use the raw body bytes exactly as received over the wire.
* **Leaving the `whsec_` prefix in** — This prefix is a type indicator only. Always strip it before computing the signature.
* **Binary-decoding the key** — The hex string must be used as ASCII bytes (64 bytes). Do not hex-decode it to binary (32 bytes).
* **Including the port in the host** — The port is excluded from the canonical request. Do not append `:port` to the host string.
* **Non-constant-time comparison** — Always use `hash_equals()` (PHP), `hmac.compare_digest()` (Python), or `crypto.timingSafeEqual()` (Node.js). Standard equality checks are vulnerable to timing attacks.
* **Ignoring the timestamp** — Always validate that the timestamp falls within your tolerance window (5 minutes recommended) to prevent replay attacks.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://help.openloyalty.io/main-features/webhooks/hmac/verifying-the-signature.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
