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

# Validating Webhook Requests

> How to validate webhook calls by using the `signingSecret` value.

Whenever a webhook is called, an `X-Syntage-Signature` header is sent to help verify it. Its value has the form `t=<timestamp>,s=<signature>`. Syntage generates the signature using a hash-based message authentication code (HMAC) with SHA-256 using the `signingSecret` as key, and sends it as a lowercase hexadecimal string.

Syntage also sends the same value in the `X-Satws-Signature` header for integrations built before the header was renamed. New integrations should read `X-Syntage-Signature`.

To verify the signature you can do the following steps:

## Step 1: Extract the timestamp and signature from the header

Split the header, using the `,` character as the separator. The value for the prefix `t` corresponds to the timestamp and `s` corresponds to the signature.

## Step 2: Prepare the signed payload

Create the signed payload by concatenating these values without spaces:

* the timestamp as a Unix timestamp string, for example `"1656569160"`
* the `.` character
* the raw request body exactly as received

## Step 3: Determine the expected signature

Compute an HMAC with the SHA-256 hash function. Use the endpoint's signing secret as the key and the signed payload as the message.

## Step 4: Compare the signatures

Compare the signature in the header to the expected signature. To protect against timing attacks, use a constant-time comparison. You should also reject timestamps outside your tolerance window.

## Examples

These examples assume you pass the raw request body exactly as received. Do not parse and re-serialize the JSON before computing the signature. In the JavaScript and Python examples, pass the value of the `X-Syntage-Signature` header as the signature header.

### PHP

```php theme={null}
<?php

$signatureHeader = $_SERVER['HTTP_X_SYNTAGE_SIGNATURE'] ?? '';
$bodyPayload = file_get_contents('php://input');
$signingSecret = getenv('SYNTAGE_WEBHOOK_SIGNING_SECRET');

if ($bodyPayload === false) {
    throw new RuntimeException('Missing webhook payload.');
}

if ($signingSecret === false) {
    throw new RuntimeException('Missing webhook signing secret.');
}

$values = [];

foreach (explode(',', $signatureHeader) as $part) {
    if (!str_contains($part, '=')) {
        throw new RuntimeException('Malformed webhook signature.');
    }

    [$key, $value] = explode('=', $part, 2);
    $values[$key][] = $value;
}

$timestamp = $values['t'][0] ?? null;
$signatures = $values['s'] ?? [];

if ($timestamp === null || $signatures === []) {
    throw new RuntimeException('Missing webhook signature.');
}

$toleranceSeconds = 300;

if (abs(time() - (int) $timestamp) > $toleranceSeconds) {
    throw new RuntimeException('Webhook signature timestamp is outside the tolerance window.');
}

$expectedSignature = hash_hmac('sha256', sprintf('%s.%s', $timestamp, $bodyPayload), $signingSecret);
$isValid = false;

foreach ($signatures as $signature) {
    if (hash_equals($expectedSignature, $signature)) {
        $isValid = true;
        break;
    }
}

if (!$isValid) {
    throw new RuntimeException('Invalid webhook signature.');
}
```

### JavaScript / TypeScript

```ts theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function validateSyntageWebhookSignature({
  signatureHeader,
  bodyPayload,
  signingSecret,
  now = Math.floor(Date.now() / 1000),
}) {
  if (!bodyPayload) {
    throw new Error("Missing webhook payload.");
  }

  if (!signingSecret) {
    throw new Error("Missing webhook signing secret.");
  }

  const values = new Map();

  for (const part of signatureHeader.split(",")) {
    const separatorIndex = part.indexOf("=");

    if (separatorIndex === -1) {
      throw new Error("Malformed webhook signature.");
    }

    const key = part.slice(0, separatorIndex);
    const value = part.slice(separatorIndex + 1);
    values.set(key, [...(values.get(key) ?? []), value]);
  }

  const timestamp = values.get("t")?.[0];
  const signatures = values.get("s") ?? [];

  if (!timestamp || signatures.length === 0) {
    throw new Error("Missing webhook signature.");
  }

  const toleranceSeconds = 300;

  if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
    throw new Error("Webhook signature timestamp is outside the tolerance window.");
  }

  const expectedSignature = createHmac("sha256", signingSecret)
    .update(`${timestamp}.${bodyPayload}`)
    .digest("hex");
  const expectedBuffer = Buffer.from(expectedSignature, "hex");

  const isValid = signatures.some((signature) => {
    const signatureBuffer = Buffer.from(signature, "hex");

    return (
      signatureBuffer.length === expectedBuffer.length &&
      timingSafeEqual(signatureBuffer, expectedBuffer)
    );
  });

  if (!isValid) {
    throw new Error("Invalid webhook signature.");
  }
}
```

### Python

```python theme={null}
import hashlib
import hmac
import time


def validate_syntage_webhook_signature(
    signature_header: str,
    body_payload: bytes,
    signing_secret: str,
    now: int | None = None,
) -> None:
    if not body_payload:
        raise ValueError("Missing webhook payload.")

    if not signing_secret:
        raise ValueError("Missing webhook signing secret.")

    values: dict[str, list[str]] = {}

    for part in signature_header.split(","):
        if "=" not in part:
            raise ValueError("Malformed webhook signature.")

        key, value = part.split("=", 1)
        values.setdefault(key, []).append(value)

    timestamp = values.get("t", [None])[0]
    signatures = values.get("s", [])

    if timestamp is None or not signatures:
        raise ValueError("Missing webhook signature.")

    now = now or int(time.time())
    tolerance_seconds = 300

    if abs(now - int(timestamp)) > tolerance_seconds:
        raise ValueError("Webhook signature timestamp is outside the tolerance window.")

    expected_signature = hmac.new(
        signing_secret.encode("utf-8"),
        f"{timestamp}.".encode("utf-8") + body_payload,
        hashlib.sha256,
    ).hexdigest()

    if not any(hmac.compare_digest(expected_signature, signature) for signature in signatures):
        raise ValueError("Invalid webhook signature.")
```

## Checking the Content-Digest header

Each delivery also includes a `Content-Digest` header, as defined in [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530). It contains the SHA-256 digest of the raw request body, encoded in base64 and wrapped in colons:

```
Content-Digest: sha-256=:<base64-encoded SHA-256 digest of the body>:
```

To check it, compute the SHA-256 digest of the raw request body, encode the binary digest in standard base64, and compare the result with the value between the colons.

The digest confirms that the body was not altered or truncated in transit. It does not prove that the request came from Syntage, because anyone can compute a digest, so always validate the signature as described above. The signature already covers the full body, which makes checking the digest optional.

```php theme={null}
$expectedDigest = sprintf('sha-256=:%s:', base64_encode(hash('sha256', $bodyPayload, true)));
$isDigestValid = hash_equals($expectedDigest, $_SERVER['HTTP_CONTENT_DIGEST'] ?? '');
```

```ts theme={null}
import { createHash } from "node:crypto";

const expectedDigest = `sha-256=:${createHash("sha256").update(bodyPayload).digest("base64")}:`;
const isDigestValid = contentDigestHeader === expectedDigest;
```

```python theme={null}
import base64
import hashlib

expected_digest = f"sha-256=:{base64.b64encode(hashlib.sha256(body_payload).digest()).decode()}:"
is_digest_valid = content_digest_header == expected_digest
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.