Meet API Docs Dashboard
On this page

Webhook Events and Signatures

The event list, and how to prove an event came from Meet.

The envelope#

Every body is JSON with the same outer shape. created is ISO 8601 UTC with milliseconds.

json
{
  "id": "evt_...",
  "type": "session.ended",
  "created": "2026-11-02T10:02:40.123Z",
  "livemode": false,
  "project": "acme-web",
  "data": { "session_id": "0c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "started_at": "2026-11-02T09:31:12Z", "ended_at": "2026-11-02T10:02:40Z", "end_reason": "empty", "room_id": "6f1c2b7e-3a54-4d0e-9c1b-2f7a8e5d4c10" }
}

Delivery is at least once, so keep the id (also sent as Meet-Event-Id) and skip events you have already handled.

Event types#

Type When data
room.created, room.updated, room.deleted A room is created, changed or deleted through the API or dashboard room_id, slug, name, visibility, shape, transcribe, created_at
session.started The first person enters session_id, started_at, room_id
session.ended The session ends session_id, started_at, ended_at, end_reason, room_id
participant.joined, participant.left Someone joins or leaves (no email address) session_id, participant_id, name, role, joined_at, left_at (left only), room_id
transcript.ready With session.ended, only if lines were spoken session_id, started_at, ended_at, room_id
recording.ready A recording finished recording_id, room_id, slug, session_id, started_at, ended_at, duration_seconds, size_bytes, mime, transcript_lines, chat_messages
chat.message Only if you list it. "*" does not include it. message_id, session_id, participant_id, author_name, body, created_at, room_id
webhook.test You call the test route. Always sent to that endpoint. message

translation.started and translation.stopped are defined names but are not sent yet. Rooms made through the signed integration API also carry external_ref in data.

Headers on every delivery: Meet-Signature, Meet-Event-Id, Meet-Event-Type and Meet-Delivery-Attempt.

The signature#

Meet-Signature: t=<unix seconds>,v1=<hex>

v1 is the hex of HMAC-SHA256(secret, "<t>.<raw body>"), where secret is your whsec_ secret. To check an event:

  1. Take the raw bytes of the body, before any JSON parsing or re-formatting.
  2. Compute the HMAC over t, a dot and the raw body.
  3. Compare with v1 in constant time. The header can hold several v1= values, so accept if any matches.
  4. Reject if t is more than 300 seconds from your clock. This stops someone replaying an old event.

Node#

js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(raw, header, secret, tolerance = 300) {   // raw is a Buffer of the unparsed body
  const parts = header.split(',').map((s) => s.trim().split('='));
  const t = parts.find(([k]) => k === 't')?.[1];
  const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
  const mine = createHmac('sha256', secret).update(`${t}.`).update(raw).digest('hex');
  const ok = sigs.some((s) => s.length === mine.length && timingSafeEqual(Buffer.from(s), Buffer.from(mine)));
  if (!ok || Math.abs(Date.now() / 1000 - Number(t)) > tolerance) throw new Error('bad signature');
}

With the SDK: Meet.webhooks.constructEvent(rawBody, header, secret).

Python#

python
import hashlib, hmac, time

def verify(raw: bytes, header: str, secret: str, tolerance: int = 300) -> None:
    parts = [p.strip().split("=", 1) for p in header.split(",")]
    t = next(v for k, v in parts if k == "t")
    sigs = [v for k, v in parts if k == "v1"]
    mine = hmac.new(secret.encode(), t.encode() + b"." + raw, hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(s, mine) for s in sigs) or abs(time.time() - int(t)) > tolerance:
        raise ValueError("bad signature")

With the SDK: Meet.webhooks.construct_event(raw_body, headers["Meet-Signature"], secret). In FastAPI use await request.body(), in Flask request.get_data().

PHP#

php
<?php
function verifyMeetSignature(string $raw, string $header, string $secret, int $tolerance = 300): void {
    $t = null; $sigs = [];
    foreach (explode(",", $header) as $part) {
        [$k, $v] = array_pad(explode("=", trim($part), 2), 2, "");
        if ($k === "t") $t = $v;
        if ($k === "v1") $sigs[] = $v;
    }
    $mine = hash_hmac("sha256", $t . "." . $raw, $secret);
    $ok = false;
    foreach ($sigs as $s) { if (hash_equals($mine, $s)) $ok = true; }
    if (!$ok || abs(time() - (int) $t) > $tolerance) {
        http_response_code(400);
        exit("bad signature");
    }
}

$raw = file_get_contents("php://input");                      // the raw body
verifyMeetSignature($raw, $_SERVER["HTTP_MEET_SIGNATURE"] ?? "", getenv("MEET_WEBHOOK_SECRET"));
$event = json_decode($raw, true);
http_response_code(204);

Go#

go
package main

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

func verify(raw []byte, header, secret string, tolerance float64) error {
	var t string
	var sigs []string
	for _, part := range strings.Split(header, ",") {
		k, v, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch k {
		case "t":
			t = v
		case "v1":
			sigs = append(sigs, v)
		}
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(raw)
	mine := mac.Sum(nil)
	ok := false
	for _, s := range sigs {
		if got, err := hex.DecodeString(s); err == nil && hmac.Equal(got, mine) {
			ok = true
		}
	}
	ts, err := strconv.ParseFloat(t, 64)
	if !ok || err != nil || math.Abs(float64(time.Now().Unix())-ts) > tolerance {
		return errors.New("bad signature")
	}
	return nil
}

func main() {
	http.HandleFunc("/meet-webhook", func(w http.ResponseWriter, r *http.Request) {
		raw, _ := io.ReadAll(r.Body)
		if err := verify(raw, r.Header.Get("Meet-Signature"), os.Getenv("MEET_WEBHOOK_SECRET"), 300); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		w.WriteHeader(http.StatusNoContent)
	})
	http.ListenAndServe(":3000", nil)
}

Test it#

Create an endpoint, then POST /api/v1/webhooks/{id}/test. A webhook.test event arrives signed like any other. The SDKs share test vectors (fixed secret, time and body) so your own implementation can be checked against known good signatures.