---
title: "Verify webhook signatures"
description: "Validate every SeatLayer webhook using the raw body, HMAC-SHA256, and a timing-safe comparison."
---

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

# Verify webhook signatures

Every webhook delivery is signed. Verify the signature before parsing or
trusting its payload.

## Signature contract

| Field | Value |
|---|---|
| Secret | The subscription's `whsec_…` secret |
| Signed message | Exact raw request-body bytes |
| Algorithm | HMAC-SHA256 |
| Encoding | Lowercase hexadecimal |
| Header | `X-SeatLayer-Signature: sha256=<hex>` |

> **Capture the raw body first**
>
> Re-serializing a parsed JSON object can change whitespace, key order, or
> number formatting. Verify the original bytes, then parse JSON.

Legacy deliveries may also include `X-SeatMap-Signature` and
`X-SeatMap-Event`. New integrations should verify
`X-SeatLayer-Signature`.

## Implement verification

### Node.js

```js title="server/webhooks.js"
import crypto from "node:crypto";
import express from "express";

function isValidSignature(rawBody, header, secret) {
  if (
    typeof header !== "string" ||
    !/^sha256=[0-9a-f]{64}$/i.test(header)
  ) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest();
  const provided = Buffer.from(header.slice("sha256=".length), "hex");

  return (
    provided.length === expected.length &&
    crypto.timingSafeEqual(provided, expected)
  );
}

app.post(
  "/webhooks/seatlayer",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const valid = isValidSignature(
      req.body,
      req.header("X-SeatLayer-Signature"),
      process.env.SEATLAYER_WEBHOOK_SECRET,
    );

    if (!valid) return res.status(401).send("invalid signature");

    const delivery = JSON.parse(req.body.toString("utf8"));
    await enqueueIdempotently(delivery);
    return res.status(200).end();
  },
);
```
### Python

```python title="server/webhooks.py"
import hashlib
import hmac
import os
from flask import request

def is_valid_signature(raw_body: bytes, header: str, secret: str) -> bool:
    if not header.startswith("sha256="):
        return False
    expected = hmac.new(
        secret.encode(),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    provided = header.removeprefix("sha256=")
    return hmac.compare_digest(expected, provided)

@app.post("/webhooks/seatlayer")
def seatlayer_webhook():
    raw_body = request.get_data(cache=True)
    header = request.headers.get("X-SeatLayer-Signature", "")
    secret = os.environ["SEATLAYER_WEBHOOK_SECRET"]

    if not is_valid_signature(raw_body, header, secret):
        return "invalid signature", 401

    enqueue_idempotently(request.get_json())
    return "", 200
```
### PHP

```php title="server/webhooks.php"
<?php
$rawBody = file_get_contents("php://input");
$header = $_SERVER["HTTP_X_SEATLAYER_SIGNATURE"] ?? "";
$secret = getenv("SEATLAYER_WEBHOOK_SECRET");

if (!str_starts_with($header, "sha256=")) {
    http_response_code(401);
    exit("invalid signature");
}

$expected = hash_hmac("sha256", $rawBody, $secret);
$provided = substr($header, strlen("sha256="));

if (!hash_equals($expected, $provided)) {
    http_response_code(401);
    exit("invalid signature");
}

$delivery = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
enqueue_idempotently($delivery);
http_response_code(200);
```

## Process after verification

1. **Reject invalid signatures**

   Return a non-2xx response and record a security-safe diagnostic. Do not log
   the secret or full signature.
2. **Parse the verified body**

   Only now decode JSON and validate the expected envelope.
3. **Deduplicate**

   Use the delivery identity and your domain state so retries cannot apply the
   same transition twice.
4. **Acknowledge quickly**

   Persist or enqueue the event, return `2xx`, and perform slow downstream work
   asynchronously.

## Secret handling and rotation

- Store each `whsec_…` value in a secret manager, never source control.
- Associate the secret with its subscription; do not try every organization
  secret for every request.
- Restrict who can reveal, replace, or delete subscriptions.
- During a planned rotation, support the old and new subscription secrets only
  for the bounded overlap required by your rollout.
- Treat a leaked webhook secret as compromised even though it cannot call the
  Server API.

## Test vectors

Test at least:

1. valid signature and unchanged raw body;
2. one-byte body modification;
3. missing prefix;
4. malformed hex and wrong length;
5. wrong subscription secret;
6. valid delivery received twice; and
7. parser failure after a valid signature.

Never use a plain string `==` or `===` for the final comparison.

## Verification checklist

- [ ] Raw bytes are captured before JSON middleware.
- [ ] The exact `X-SeatLayer-Signature` header is required.
- [ ] HMAC uses SHA-256 and the correct subscription secret.
- [ ] The comparison is timing-safe and length-safe.
- [ ] Invalid signatures receive a non-2xx response.
- [ ] Verified deliveries are deduplicated and acknowledged quickly.
- [ ] Secrets and full signatures are absent from logs.

Continue with [integrate a ticketing backend](/integrations/ticketing-backend).

Source: https://docs.seatlayer.io/webhooks/signatures/index.mdx
