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

# Verify signatures

> Confirm that incoming webhook requests genuinely originated from Openlayer.

Because your webhook endpoint is a public URL, anyone could send requests to it.
To confirm that a request genuinely came from Openlayer and was not tampered
with, every delivery is signed. Your endpoint should **verify the signature
before processing the payload**.

Openlayer follows the [Standard Webhooks](https://www.standardwebhooks.com/)
specification, so you can verify signatures with any compatible library.

## Signature headers

Every webhook request includes the following headers:

| Header | Description |
| - | - |
| `webhook-id` | A unique identifier for the message. It stays constant across retries of the same event. |
| `webhook-timestamp` | The Unix timestamp (in seconds) of the delivery attempt. |
| `webhook-signature` | A space-delimited list of signatures, each prefixed with its version (for example, `v1,…`). |

Requests are also sent with `Content-Type: application/json` and a
`User-Agent` of `Openlayer-Webhooks/1.0`.

## How the signature is computed

The signature is an HMAC-SHA256 over the webhook id, timestamp, and the raw
request body, joined with periods:

```text theme={null}
signed_content = "{webhook-id}.{webhook-timestamp}.{raw_body}"
```

The key is your subscription's signing secret with the `whsec_` prefix removed
and the remainder Base64-decoded. The result is Base64-encoded and prefixed with
`v1,` to form the value sent in the `webhook-signature` header:

```text theme={null}
signature = "v1," + base64(HMAC_SHA256(base64decode(secret_without_prefix), signed_content))
```

> [!WARNING]
>
> Verify against the **raw request body** exactly as received. Parsing the JSON
> and re-serializing it can change the bytes (key order, whitespace) and cause
> verification to fail.

## Verify with a library (recommended)

The [Standard Webhooks](https://www.standardwebhooks.com/) libraries handle
signature construction, Base64 decoding, constant-time comparison, and timestamp
checks for you. Pass the signing secret returned when you created the
subscription.

```python Python theme={null}
# pip install standardwebhooks
from standardwebhooks import Webhook

# The secret returned when the subscription was created, e.g. "whsec_..."
secret = "YOUR_WEBHOOK_SIGNING_SECRET"

def handle_request(raw_body: bytes, headers: dict):
    wh = Webhook(secret)
    # Raises an error if the signature or timestamp is invalid.
    payload = wh.verify(raw_body, {
        "webhook-id": headers["webhook-id"],
        "webhook-timestamp": headers["webhook-timestamp"],
        "webhook-signature": headers["webhook-signature"],
    })
    # payload is the verified, parsed event.
    return payload
```

```javascript JavaScript theme={null}
// npm install standardwebhooks
import { Webhook } from "standardwebhooks";

// The secret returned when the subscription was created, e.g. "whsec_..."
const secret = "YOUR_WEBHOOK_SIGNING_SECRET";

function handleRequest(rawBody, headers) {
  const wh = new Webhook(secret);
  // Throws if the signature or timestamp is invalid.
  const payload = wh.verify(rawBody, {
    "webhook-id": headers["webhook-id"],
    "webhook-timestamp": headers["webhook-timestamp"],
    "webhook-signature": headers["webhook-signature"],
  });
  // payload is the verified, parsed event.
  return payload;
}
```

## Verify manually

If you prefer not to add a dependency, you can reproduce the signature yourself
and compare it to the header using a constant-time comparison.

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

def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
    webhook_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]

    signed_content = f"{webhook_id}.{timestamp}.{raw_body.decode('utf-8')}"
    key = base64.b64decode(secret.removeprefix("whsec_"))
    digest = hmac.new(key, signed_content.encode("utf-8"), hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode("utf-8")

    # The header may contain multiple space-delimited signatures.
    for part in headers["webhook-signature"].split(" "):
        version, _, value = part.partition(",")
        if version == "v1" and hmac.compare_digest(value, expected):
            return True
    return False
```

```javascript JavaScript theme={null}
import crypto from "crypto";

function verify(rawBody, headers, secret) {
  const webhookId = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];

  const signedContent = `${webhookId}.${timestamp}.${rawBody}`;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(signedContent)
    .digest("base64");

  // The header may contain multiple space-delimited signatures.
  return headers["webhook-signature"].split(" ").some((part) => {
    const [version, value] = part.split(",");
    return (
      version === "v1" &&
      value.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected))
    );
  });
}
```

## Replay protection

The `webhook-timestamp` header lets you reject stale requests. Compare it to the
current time and discard requests whose timestamp is outside a tolerance window
(for example, more than 5 minutes old). The Standard Webhooks libraries perform
this check for you.

## Idempotency

Because deliveries are [retried](https://docs.openlayer.com/security/webhooks/overview.md#retries), your
endpoint may receive the same event more than once. The `webhook-id` header is
constant across retries of the same event, so you can use it as an idempotency
key — record the IDs you've already processed and skip duplicates.


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