meetbot / docs
Basics

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

  1. Read the raw request body as bytes — do not parse JSON first, the signature is computed over the exact bytes we sent.
  2. Read the x-meetbot-signature header.
  3. Compute HMAC-SHA256 of the body using your webhook secret.
  4. Compare the two using a constant-time comparison (crypto.timingSafeEqual in Node, hmac.compare_digest in Python, hmac.Equal in Go).

The webhook secret is at /account/keys under the "Webhook signing secret" row. It starts with whsec_….

JavaScript / Node

webhook.ts
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

webhook.py
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

webhook.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 you req.body already parsed. You'll need express.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.

On this page