Webhooks — Signature Verification
Every meetbot webhook is signed with HMAC-SHA256. Reject anything that doesn't verify. JavaScript, Python, and Go snippets included.
Every webhook we send carries an x-meetbot-signature header in the form
sha256=<hex>. The hex is HMAC-SHA256(webhook_secret, raw_request_body).
You must verify this signature before parsing the body. Anyone with your webhook URL could otherwise POST garbage at it; the signature is what proves the request came from us.
The recipe
- Read the raw request body as bytes — do not parse JSON first, the signature is computed over the exact bytes we sent.
- Read the
x-meetbot-signatureheader. - Compute
HMAC-SHA256of the body using your webhook secret. - Compare the two using a constant-time comparison
(
crypto.timingSafeEqualin Node,hmac.compare_digestin Python,hmac.Equalin Go).
The webhook secret is at
/account/keys under the
"Webhook signing secret" row. It starts with whsec_….
JavaScript / Node
import crypto from "node:crypto";
export function verifyMeetbotWebhook(
rawBody: string | Buffer,
signatureHeader: string | null,
secret: string,
): boolean {
if (!signatureHeader?.startsWith("sha256=")) return false;
const provided = Buffer.from(signatureHeader.slice("sha256=".length), "hex");
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest();
return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
}In an Express / Fastify / Next.js route:
// Next.js App Router — req.text() returns the *raw* body string.
export async function POST(req: Request) {
const raw = await req.text();
if (
!verifyMeetbotWebhook(
raw,
req.headers.get("x-meetbot-signature"),
process.env.MEETBOT_WEBHOOK_SECRET!,
)
) {
return new Response("invalid signature", { status: 401 });
}
const event = JSON.parse(raw);
// ... handle event.type
return new Response("ok");
}The @meetbot/sdk package re-exports this as verifyWebhookSignature
so you don't have to copy-paste it.
Python
import hmac
import hashlib
def verify_meetbot_webhook(raw_body: bytes, signature_header: str | None, secret: str) -> bool:
if not signature_header or not signature_header.startswith("sha256="):
return False
provided = bytes.fromhex(signature_header[len("sha256="):])
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
return hmac.compare_digest(provided, expected)Flask handler:
from flask import request, abort
@app.post("/webhook/meetbot")
def meetbot_hook():
raw = request.get_data() # bytes, not request.json
if not verify_meetbot_webhook(
raw, request.headers.get("X-Meetbot-Signature"), os.environ["MEETBOT_WEBHOOK_SECRET"]
):
abort(401)
event = request.get_json()
# ...
return "ok"Go
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func VerifyMeetbotWebhook(rawBody []byte, signatureHeader, secret string) bool {
if !strings.HasPrefix(signatureHeader, "sha256=") {
return false
}
provided, err := hex.DecodeString(signatureHeader[len("sha256="):])
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
return hmac.Equal(provided, mac.Sum(nil))
}net/http handler:
func handler(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
if !VerifyMeetbotWebhook(raw, r.Header.Get("X-Meetbot-Signature"), os.Getenv("MEETBOT_WEBHOOK_SECRET")) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// parse + handle
}Curl test from the dashboard
The dashboard has a Resend button on each delivered webhook at
/account/recordings/:id. Use it after deploying a verifier change to
sanity-check end-to-end without waiting for the next real meeting.
Common gotchas
- Body parser ate the bytes. Express's
express.json()middleware consumes the stream and gives youreq.bodyalready parsed. You'll needexpress.raw({ type: "application/json" })on this route specifically. - You re-stringified the JSON.
JSON.stringify(JSON.parse(raw))is not byte-for-byte equal to the original — key ordering and whitespace diverge. Always hash the bytes you received on the wire. - You compared with
===or==. Use a constant-time compare; a timing oracle on a 32-byte hex string is well within an attacker's budget. - Multiple signatures. We currently send exactly one
x-meetbot-signature. When we add a second header for key rotation (planned M2), it will be CSV-formatted (sha256=…,sha256=…) — accept if any entry verifies.