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 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
- Take the secret, drop the
whsec_at its start, and base64-decode the rest. Those bytes are the key. - 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. - Compute HMAC-SHA256 of that string with the key, and base64-encode the result.
- Compare it with each
v1,value inwebhook-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.
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;
}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:
import { Webhook } from 'standardwebhooks';
const wh = new Webhook(process.env.BLUEBOT_WEBHOOK_SECRET!);
const event = wh.verify(payload, headers); // throws on a bad signatureWithout 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.
// 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 });
}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.
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 appOther 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
$_SERVERturnwebhook-idintoHTTP_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. |
| 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.