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

# Verify webhook signatures

> Check X-Webhook-Signature with HMAC-SHA256 over the raw body, compare in constant time, and guard against replays.

export const Screenshot = ({id, src, srcDark, alt, caption}) => {
  if (!src) {
    return <Placeholder id={id} kind="screenshot" label="Screenshot coming soon" description={alt} icon={<ImageIcon />} />;
  }
  return <figure className="brd-media" data-media-id={id}>
      <img className="brd-media-frame block dark:hidden" src={src} alt={alt} />
      <img className="brd-media-frame hidden dark:block" src={srcDark || src} alt={alt} />
      {caption && <figcaption className="brd-media-caption">{caption}</figcaption>}
    </figure>;
};

Your endpoint's URL is public, so anyone can send it a `POST`. Verify the signature on each request
so your handler only acts on deliveries that came from Brudcast.

<Screenshot id="ss-developers-verifying-signatures-hero-secret" alt="The Signing Secret dialog showing the 64-character secret with a copy button, shown once after an endpoint is created" />

<Info>
  **Before you start:** you need the endpoint's signing secret, which is shown once when you create
  the endpoint or rotate its secret. Your framework must also let you read the request body as
  raw bytes, before any JSON parsing.
</Info>

## How Brudcast signs a request

`X-Webhook-Signature` is:

* an HMAC-SHA256
* keyed with the endpoint's secret, used as-is, as a UTF-8 string
* computed over the raw request body, byte for byte
* written as lowercase hexadecimal, 64 characters, with no prefix such as `sha256=`

No timestamp goes into the signature, and there's no separate timestamp header. The time is inside
the body, as `timestamp`, so the signature covers it too.

<Warning>
  The secret looks like hex, but don't hex-decode it. Brudcast uses the 64-character string itself
  as the HMAC key.
</Warning>

## Verify each request

<Steps>
  <Step title="Read the raw body">
    Capture the exact bytes Brudcast sent. If a JSON parser reads the body first, and you then
    serialize it again, key order and spacing can change and the signature won't match.
  </Step>

  <Step title="Compute the expected signature">
    Calculate HMAC-SHA256 of the raw body, keyed with your secret, and hex-encode it in lowercase.
  </Step>

  <Step title="Compare in constant time">
    Compare your value with `X-Webhook-Signature` using a constant-time function. A normal string
    comparison stops at the first difference, and the time it takes can leak how much of a forged
    signature was right. If the values differ, return `401` and stop.
  </Step>

  <Step title="Check the timestamp">
    Parse the body and reject deliveries whose `timestamp` is older than your tolerance. See
    [Choose a tolerance](#choose-a-timestamp-tolerance) below.
  </Step>

  <Step title="Skip repeats">
    Look up `delivery_id` in your store. If you've already processed it, return `200` and do
    nothing. Otherwise, record it along with the event.
  </Step>

  <Step title="Respond quickly">
    Return a `2xx` once the event is stored, and do the real work afterwards. If your handler takes
    longer than the endpoint's timeout, Brudcast treats the attempt as failed and sends the same
    delivery again.
  </Step>
</Steps>

## Code

Each example reads the secret from `BRUDCAST_WEBHOOK_SECRET` and listens on
`/webhooks/brudcast`.

<CodeGroup>
  ```javascript Node.js (Express) theme={"system"}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  const secret = process.env.BRUDCAST_WEBHOOK_SECRET;
  const TOLERANCE_SECONDS = 24 * 60 * 60;

  // express.raw() keeps the body as a Buffer, exactly as it was sent.
  app.post(
    "/webhooks/brudcast",
    express.raw({ type: "application/json" }),
    (req, res) => {
      if (!Buffer.isBuffer(req.body)) {
        return res.status(400).send("Expected a JSON body");
      }

      const expected = crypto
        .createHmac("sha256", secret)
        .update(req.body)
        .digest("hex");
      const received = req.get("X-Webhook-Signature") ?? "";

      const a = Buffer.from(expected, "utf8");
      const b = Buffer.from(received, "utf8");
      if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
        return res.status(401).send("Invalid signature");
      }

      const event = JSON.parse(req.body.toString("utf8"));
      const age = Math.abs(Date.now() / 1000 - event.timestamp);
      if (age > TOLERANCE_SECONDS) {
        return res.status(400).send("Stale delivery");
      }

      // Store the event, keyed by event.delivery_id, and skip it if it's already there.
      res.sendStatus(200);
    },
  );

  app.listen(3000);
  ```

  ```python Python (Flask) theme={"system"}
  import hashlib
  import hmac
  import json
  import os
  import time

  from flask import Flask, abort, request

  app = Flask(__name__)
  SECRET = os.environ["BRUDCAST_WEBHOOK_SECRET"].encode("utf-8")
  TOLERANCE_SECONDS = 24 * 60 * 60


  @app.post("/webhooks/brudcast")
  def brudcast_webhook():
      raw_body = request.get_data()  # the exact bytes that were sent

      expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
      received = request.headers.get("X-Webhook-Signature", "")

      if not hmac.compare_digest(expected.encode("utf-8"), received.encode("utf-8")):
          abort(401)

      event = json.loads(raw_body)
      if abs(time.time() - event["timestamp"]) > TOLERANCE_SECONDS:
          abort(400)

      # Store the event, keyed by event["delivery_id"], and skip it if it's already there.
      return "", 200
  ```

  ```php PHP theme={"system"}
  <?php
  $secret = getenv('BRUDCAST_WEBHOOK_SECRET');
  $toleranceSeconds = 24 * 60 * 60;

  $rawBody = file_get_contents('php://input'); // the exact bytes that were sent

  $expected = hash_hmac('sha256', $rawBody, $secret);
  $received = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

  if (!hash_equals($expected, $received)) {
      http_response_code(401);
      exit('Invalid signature');
  }

  $event = json_decode($rawBody, true);
  if (!is_array($event) || abs(time() - (int) $event['timestamp']) > $toleranceSeconds) {
      http_response_code(400);
      exit('Stale delivery');
  }

  // Store the event, keyed by $event['delivery_id'], and skip it if it's already there.
  http_response_code(200);
  ```

  ```go Go theme={"system"}
  package main

  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"encoding/json"
  	"io"
  	"log"
  	"net/http"
  	"os"
  	"time"
  )

  const toleranceSeconds = 24 * 60 * 60

  type webhookEvent struct {
  	DeliveryID string          `json:"delivery_id"`
  	Event      string          `json:"event"`
  	Data       json.RawMessage `json:"data"`
  	Timestamp  int64           `json:"timestamp"`
  }

  func brudcastWebhook(w http.ResponseWriter, r *http.Request) {
  	if r.Method != http.MethodPost {
  		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
  		return
  	}

  	body, err := io.ReadAll(r.Body) // the exact bytes that were sent
  	if err != nil {
  		http.Error(w, "cannot read body", http.StatusBadRequest)
  		return
  	}

  	mac := hmac.New(sha256.New, []byte(os.Getenv("BRUDCAST_WEBHOOK_SECRET")))
  	mac.Write(body)
  	expected := hex.EncodeToString(mac.Sum(nil))
  	received := r.Header.Get("X-Webhook-Signature")

  	if !hmac.Equal([]byte(expected), []byte(received)) {
  		http.Error(w, "invalid signature", http.StatusUnauthorized)
  		return
  	}

  	var event webhookEvent
  	if err := json.Unmarshal(body, &event); err != nil {
  		http.Error(w, "invalid JSON", http.StatusBadRequest)
  		return
  	}

  	age := time.Now().Unix() - event.Timestamp
  	if age > toleranceSeconds || age < -toleranceSeconds {
  		http.Error(w, "stale delivery", http.StatusBadRequest)
  		return
  	}

  	// Store the event, keyed by event.DeliveryID, and skip it if it's already there.
  	w.WriteHeader(http.StatusOK)
  }

  func main() {
  	http.HandleFunc("/webhooks/brudcast", brudcastWebhook)
  	log.Fatal(http.ListenAndServe(":3000", nil))
  }
  ```
</CodeGroup>

## Choose a timestamp tolerance

`timestamp` is the time Brudcast recorded the delivery, and it doesn't change. A retry, or a
resend you trigger by hand, arrives with the original value. A tight window, such as five minutes,
rejects legitimate retries on endpoints with many attempts, and any resend you make later.

Your `delivery_id` check is what stops a captured request from being replayed. The timestamp
limits how long you need to remember those IDs. Pick a tolerance that covers your endpoint's retry
schedule, and keep processed IDs for at least that long.

| Max Retries (total attempts) | Last automatic attempt, after the first |
| ---------------------------- | --------------------------------------- |
| 5 (default)                  | About 75 seconds                        |
| 10                           | About 43 minutes                        |
| 15                           | About 23 hours                          |
| 20                           | About 5 days 22 hours                   |

The full schedule is in
[Retries and deliveries](/developers/webhooks/create-an-endpoint#retries-and-deliveries). The
examples above use 24 hours.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The signature never matches" icon="circle-alert">
    **Why:** usually the body was changed before you hashed it. A JSON middleware may have parsed
    it, a framework may have decoded it, or a proxy may have re-encoded it. Less often, the secret
    is wrong: it has a trailing newline from an environment file, it was hex-decoded, or it's from
    before a rotation.

    **Fix:** hash the raw bytes, as in the examples. Print the length of the secret you load: it
    should be 64. If you've lost the secret, rotate it and update your server.
  </Accordion>

  <Accordion title="Deliveries my server rejected with 401 aren't retried" icon="circle-alert">
    **Why:** Brudcast retries only when it gets no response. A `401` is a response, so the delivery
    is marked delivered.

    **Fix:** fix the verification, then reprocess what you missed. Open each delivery in **Delivery
    Logs**. Its **Request Body** holds the event's `data`.
  </Accordion>

  <Accordion title="The same event arrives twice" icon="circle-alert">
    **Why:** your server took longer than the endpoint's timeout, so Brudcast treated the attempt as
    failed and tried again with the same body.

    **Fix:** skip any `delivery_id` you've already processed, and return `200` before doing slow
    work.
  </Accordion>
</AccordionGroup>

<Columns cols={2}>
  <Card title="Payload reference" icon="braces" href="/developers/webhooks/payload-reference">
    The headers and fields you're verifying.
  </Card>

  <Card title="Create an endpoint" icon="plus" href="/developers/webhooks/create-an-endpoint#test-from-your-own-machine">
    Point an endpoint at your own machine while you build.
  </Card>
</Columns>
