# Verify signatures

> Check that each webhook post came from BlueBot and wasn't changed on the way, with the SDKs, any Standard Webhooks library, or a few lines of code in your language.

Your webhook address is on the public internet, so anybody who learns it can post to it. Every post from BlueBot is signed with your webhook's secret, and checking that signature is the only way to know a "Maria replied" event is real. Check it on every request, before you act on anything in the body.

BlueBot signs the [Standard Webhooks](https://www.standardwebhooks.com) way, so the SDKs, the official Standard Webhooks libraries and the code below all agree.

## The three headers

| Header              | What it holds                                                                                                                    |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `webhook-id`        | The event's id, `evt_…`. The same on every retry and replay of the event.                                                        |
| `webhook-timestamp` | When this attempt was signed, in whole seconds since 1970 (Unix time). Each retry is signed afresh, so it carries a new time.    |
| `webhook-signature` | One or more signatures, separated by spaces, each written `v1,<base64>`. There are two for 24 hours after you rotate the secret. |

## How the signature is made

1. Take the secret, drop the `whsec_` at its start, and base64-decode the rest. Those bytes are the key.
2. Join the id, the timestamp and the raw body with dots: `<webhook-id>.<webhook-timestamp>.<body>`. The body is the exact bytes BlueBot sent, before any JSON parsing.
3. Compute HMAC-SHA256 of that string with the key, and base64-encode the result.
4. Compare it with each `v1,` value in `webhook-signature`, using a constant-time comparison. If any one matches, the post is genuine.

Then check the time: refuse a `webhook-timestamp` more than **5 minutes** away from your own clock, before or after. That stops anybody who captured a real post from sending it to you again later. Because BlueBot signs each attempt when it makes it, a genuine retry an hour later still has a fresh timestamp and passes. Keep your server's clock synced (NTP is on by default almost everywhere).

## With the SDKs

The SDKs do all of it in one call and give you the event, typed. They read the three headers from whatever your framework hands you: a `Headers` object, a plain object or a list of pairs.

```ts title="TypeScript"
import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

// payload: the raw body as a string or bytes. headers: the request's headers.
try {
  const event = client.webhooks.verify(payload, headers, process.env.BLUEBOT_WEBHOOK_SECRET!);
  console.log(event.type, event.id);
} catch (error) {
  if (error instanceof WebhookVerificationError) {
    // Answer 400 and do nothing else.
  }
  throw error;
}
```

```python title="Python"
import os

from bluebot import BlueBot, WebhookVerificationError

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

# payload: the raw body as bytes or str. headers: the request's headers, any mapping.
try:
    event = client.webhooks.verify(payload, headers, os.environ["BLUEBOT_WEBHOOK_SECRET"])
    print(event.type, event.id)
except WebhookVerificationError:
    # Answer 400 and do nothing else.
    raise
```

`verify` checks the signature and the 5-minute window, then parses the body. It throws `WebhookVerificationError` (Python raises `bluebot.WebhookVerificationError`) when no signature matches, a header is missing, or the timestamp is out of the window. It never calls BlueBot, so it works offline and costs no request.

You don't need BlueBot's SDK to verify. The official Standard Webhooks libraries take a BlueBot secret as it is, `whsec_` and all:

```ts title="TypeScript"
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.BLUEBOT_WEBHOOK_SECRET!);
const event = wh.verify(payload, headers); // throws on a bad signature
```

```python title="Python"
import os

from standardwebhooks.webhooks import Webhook

wh = Webhook(os.environ["BLUEBOT_WEBHOOK_SECRET"])
event = wh.verify(payload, headers)  # raises on a bad signature
```

## Without a library

With no library at all, this is the whole check. Each example reads the raw body, verifies it, then handles a `message.received`.

```ts title="TypeScript"
// app/api/bluebot/route.ts (Next.js). Any framework works: verify the raw body before parsing it.
import BlueBot, { WebhookVerificationError } from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

export async function POST(request: Request) {
  const payload = await request.text(); // the raw body, byte for byte
  let event;
  try {
    event = client.webhooks.verify(payload, request.headers, process.env.BLUEBOT_WEBHOOK_SECRET);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response('Bad signature', { status: 400 });
    throw error;
  }

  if (event.type === 'message.received') {
    // For a message event, data is the message itself.
    const { from, text } = event.data;
    console.log(`${from}: ${text}`);
  }
  return new Response(null, { status: 204 });
}
```

```python title="Python"
# Flask. Any framework works: verify the raw body before parsing it.
import os
from flask import Flask, abort, request
from bluebot import BlueBot, WebhookVerificationError

app = Flask(__name__)
client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

@app.post("/bluebot/webhooks")
def bluebot_webhook():
    try:
        event = client.webhooks.verify(
            request.get_data(),  # the raw body, byte for byte
            request.headers,
            secret=os.environ["BLUEBOT_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        abort(400)

    if event.type == "message.received":
        message = event.data  # for a message event, data is the message itself
        print(f"{message.from_}: {message.text}")  # `from` is `from_` in Python
    return "", 204
```

```go title="Go"
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

// verify checks a BlueBot webhook the Standard Webhooks way.
func verify(secret string, header http.Header, body []byte) error {
	id := header.Get("webhook-id")
	timestamp := header.Get("webhook-timestamp")
	sent, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return errors.New("no timestamp")
	}
	if age := time.Since(time.Unix(sent, 0)); age > 5*time.Minute || age < -5*time.Minute {
		return errors.New("timestamp too old or too new")
	}
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return err
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + timestamp + "." + string(body)))
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	for _, signature := range strings.Fields(header.Get("webhook-signature")) {
		version, value, ok := strings.Cut(signature, ",")
		if ok && version == "v1" && hmac.Equal([]byte(value), []byte(expected)) {
			return nil
		}
	}
	return errors.New("no signature matched")
}

func handler(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body) // the raw body, byte for byte
	if err := verify(os.Getenv("BLUEBOT_WEBHOOK_SECRET"), r.Header, body); err != nil {
		http.Error(w, "bad signature", http.StatusBadRequest)
		return
	}
	// json.Unmarshal(body, &event) and handle it.
	w.WriteHeader(http.StatusNoContent)
}

func main() {
	http.HandleFunc("/bluebot/webhooks", handler)
	http.ListenAndServe(":8080", nil)
}
```

```ruby title="Ruby"
require "base64"
require "json"
require "openssl"
require "sinatra"

def verify_bluebot!(secret, headers, body)
  id = headers["HTTP_WEBHOOK_ID"]
  timestamp = headers["HTTP_WEBHOOK_TIMESTAMP"]
  raise "timestamp too old or too new" if (Time.now.to_i - timestamp.to_i).abs > 300

  key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
  expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}"))
  matched = headers["HTTP_WEBHOOK_SIGNATURE"].to_s.split(" ").any? do |signature|
    version, value = signature.split(",", 2)
    version == "v1" && Rack::Utils.secure_compare(value.to_s, expected)
  end
  raise "no signature matched" unless matched
end

post "/bluebot/webhooks" do
  body = request.body.read # the raw body, byte for byte
  begin
    verify_bluebot!(ENV.fetch("BLUEBOT_WEBHOOK_SECRET"), request.env, body)
  rescue StandardError
    halt 400, "bad signature"
  end

  event = JSON.parse(body)
  puts "#{event["data"]["from"]}: #{event["data"]["text"]}" if event["type"] == "message.received"
  status 204
end
```

```php title="PHP"
<?php

function verify_bluebot(string $secret, array $headers, string $body): bool
{
    $id = $headers['webhook-id'] ?? '';
    $timestamp = $headers['webhook-timestamp'] ?? '';
    if (abs(time() - (int) $timestamp) > 300) {
        return false; // too old or too new
    }
    $key = base64_decode(substr($secret, strlen('whsec_')));
    $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$body", $key, true));
    foreach (explode(' ', $headers['webhook-signature'] ?? '') as $signature) {
        [$version, $value] = array_pad(explode(',', $signature, 2), 2, '');
        if ($version === 'v1' && hash_equals($expected, $value)) {
            return true;
        }
    }
    return false;
}

$body = file_get_contents('php://input'); // the raw body, byte for byte
$headers = array_change_key_case(getallheaders(), CASE_LOWER);

if (!verify_bluebot(getenv('BLUEBOT_WEBHOOK_SECRET'), $headers, $body)) {
    http_response_code(400);
    exit('bad signature');
}

$event = json_decode($body, true);
if ($event['type'] === 'message.received') {
    error_log($event['data']['from'] . ': ' . $event['data']['text']);
}
http_response_code(204);
```

```js title="JavaScript"
// Express, with no SDK: node:crypto only.
import crypto from 'node:crypto';
import express from 'express';

function verifyBlueBot(secret, headers, body) {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // too old or too new
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64');
  return (headers['webhook-signature'] ?? '').split(' ').some((signature) => {
    const [version, value = ''] = signature.split(',');
    return version === 'v1' && value.length === expected.length && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(expected));
  });
}

const app = express();

// express.raw keeps the body as bytes; parsing JSON first would change it and break the signature.
app.post('/bluebot/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const body = req.body.toString('utf8');
  if (!verifyBlueBot(process.env.BLUEBOT_WEBHOOK_SECRET, req.headers, body)) return res.status(400).send('bad signature');
  const event = JSON.parse(body);
  if (event.type === 'message.received') console.log(`${event.data.from}: ${event.data.text}`);
  res.sendStatus(204);
});

app.listen(3000);
```

```bash title="Shell"
# Check one delivery by hand: paste the three headers and the raw body your endpoint logged for it.
WEBHOOK_ID='evt_6Tn2xL9mQ0cR4bW8kV1sHd'  # the webhook-id header
WEBHOOK_TIMESTAMP='1763046273'   # the webhook-timestamp header
BODY='{"id":"evt_…","type":"message.received",…}'
SECRET='whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'

KEY_HEX=$(printf '%s' "${SECRET#whsec_}" | base64 --decode | xxd -p -c 256)
printf '%s' "$WEBHOOK_ID.$WEBHOOK_TIMESTAMP.$BODY" \
  | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary \
  | base64
# Compare the output with the value after "v1," in the webhook-signature header.
```

> **Compare in constant time.** Use `crypto.timingSafeEqual`, `hmac.compare_digest`, `hmac.Equal`, `hash_equals` or `Rack::Utils.secure_compare`, never `==`. An ordinary comparison stops at the first different character, and the time it takes can leak the right signature one character at a time.

## Keep the raw body

Almost every failed check is a body that changed before it was verified. A framework that parses JSON and you then turn back into a string gives different bytes: key order, spacing and escaped characters all move. Verify the bytes as they arrived, then parse them.

```js title="Express"
import express from 'express';

const app = express();

// Mount the webhook route with express.raw BEFORE any app.use(express.json()),
// or express.json() will have read and parsed the body first.
app.post('/bluebot/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = req.body; // a Buffer of the exact bytes
  // verify(payload, req.headers, secret), then JSON.parse(payload.toString('utf8'))
  res.sendStatus(204);
});

app.use(express.json()); // the rest of your app
```

```ts title="Next.js"
// app/api/bluebot/webhooks/route.ts (App Router route handler)
export async function POST(request: Request) {
  const payload = await request.text(); // read it once, as text; never request.json() first
  // verify(payload, request.headers, secret), then JSON.parse(payload)
  return new Response(null, { status: 204 });
}

// Pages Router (pages/api/...) parses the body unless you turn that off:
// export const config = { api: { bodyParser: false } };
// then read the stream into a Buffer yourself.
```

```python title="FastAPI"
from fastapi import FastAPI, Request, Response

app = FastAPI()

@app.post("/bluebot/webhooks")
async def bluebot_webhook(request: Request):
    payload = await request.body()  # bytes, as sent
    # Don't declare a Pydantic model parameter for the body: FastAPI would parse it first.
    # verify(payload, request.headers, secret)
    return Response(status_code=204)
```

```python title="Flask"
from flask import Flask, request

app = Flask(__name__)

@app.post("/bluebot/webhooks")
def bluebot_webhook():
    payload = request.get_data()  # bytes, as sent; call it before request.json
    # verify(payload, request.headers, secret)
    return "", 204
```

```ruby title="Rails"
class BluebotWebhooksController < ActionController::API
  # ActionController::API has no CSRF check. In an ActionController::Base controller, add:
  # skip_before_action :verify_authenticity_token

  def create
    payload = request.raw_post # the body, as sent
    headers = {
      "webhook-id" => request.headers["webhook-id"],
      "webhook-timestamp" => request.headers["webhook-timestamp"],
      "webhook-signature" => request.headers["webhook-signature"],
    }
    # verify(payload, headers, ENV.fetch("BLUEBOT_WEBHOOK_SECRET")), then JSON.parse(payload)
    head :no_content
  end
end
```

```php title="Laravel"
<?php

// routes/api.php: routes there have no CSRF check. In routes/web.php, exclude the path:
// Laravel 11 and later, bootstrap/app.php:
//   ->withMiddleware(fn ($m) => $m->validateCsrfTokens(except: ['bluebot/webhooks']))
// Laravel 10, App\Http\Middleware\VerifyCsrfToken: protected $except = ['bluebot/webhooks'];

use Illuminate\Http\Request;

Route::post('/bluebot/webhooks', function (Request $request) {
    $payload = $request->getContent(); // the body, as sent
    $headers = [
        'webhook-id' => $request->header('webhook-id'),
        'webhook-timestamp' => $request->header('webhook-timestamp'),
        'webhook-signature' => $request->header('webhook-signature'),
    ];
    // verify($payload, $headers, env('BLUEBOT_WEBHOOK_SECRET')), then json_decode($payload, true)
    return response()->noContent();
});
```

```go title="Go net/http"
func handler(w http.ResponseWriter, r *http.Request) {
	// Cap the size, then read the body once. r.Body can't be read twice.
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		http.Error(w, "too large", http.StatusRequestEntityTooLarge)
		return
	}
	// verify(secret, r.Header, body), then json.Unmarshal(body, &event)
	w.WriteHeader(http.StatusNoContent)
}
```

Other places a body changes on the way:

- **A proxy or gateway that rewrites JSON.** Some API gateways, edge functions and logging middleware re-encode bodies. Verify in the first code that sees the request, or pass the original body through untouched.
- **Character encoding.** Read the body as bytes, or as UTF-8 text. Message words often carry accents, emoji and curly quotes, and a body decoded as Latin-1 won't match.
- **Header names.** HTTP header names are case-insensitive, and frameworks change their case: Rack and PHP's `$_SERVER` turn `webhook-id` into `HTTP_WEBHOOK_ID`. Look them up the way your framework expects.

## When a check fails

| You got                                     | It means                                                                             | Do next                                                                                                                                                       |
| ------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| No signature matched, every time            | The body changed before you verified it, or the secret is wrong.                     | Log the length of the body you verify and compare it with the request's `Content-Length`. Check you're using this webhook's secret: each webhook has its own. |
| No signature matched, only after a rotation | You still verify with the old secret, and its 24 hours are up.                       | Put the new secret in your environment. [Rotate secrets](https://usebluebot.com/docs/webhooks/rotate-secrets)                                                                       |
| Timestamp out of the window                 | Your server's clock is off by more than 5 minutes, or somebody sent you an old post. | Sync the clock. If it's right, the refusal did its job.                                                                                                       |
| A header is missing                         | The post didn't come from BlueBot, or a proxy dropped the headers.                   | Pass `webhook-id`, `webhook-timestamp` and `webhook-signature` through any proxy in front of your app.                                                        |

Answer a failed check with `400` and do nothing else. A genuine post that failed because of a bug on your side is retried by BlueBot for 72 hours, so once you fix the bug, those events still arrive. [Retries and the deliveries log](https://usebluebot.com/docs/webhooks/retries)

## Next

- [Test webhooks](https://usebluebot.com/docs/webhooks/testing): Send yourself a signed test event and check your handler with it.
- [Rotate secrets](https://usebluebot.com/docs/webhooks/rotate-secrets): Change the secret without missing a delivery.
