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

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>` |

<Aside type="caution" title="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.
</Aside>

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

## Implement verification

<Tabs>
  <TabItem label="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();
      },
    );
    ```
  </TabItem>
  <TabItem label="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
    ```
  </TabItem>
  <TabItem label="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);
    ```
  </TabItem>
</Tabs>

## Process after verification

<Steps>
  <Step title="Reject invalid signatures">
    Return a non-2xx response and record a security-safe diagnostic. Do not log
    the secret or full signature.
  </Step>
  <Step title="Parse the verified body">
    Only now decode JSON and validate the expected envelope.
  </Step>
  <Step title="Deduplicate">
    Use the delivery identity and your domain state so retries cannot apply the
    same transition twice.
  </Step>
  <Step title="Acknowledge quickly">
    Persist or enqueue the event, return `2xx`, and perform slow downstream work
    asynchronously.
  </Step>
</Steps>

## 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).