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.
{
"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:
- Take the raw bytes of the body, before any JSON parsing or re-formatting.
- Compute the HMAC over
t, a dot and the raw body. - Compare with
v1in constant time. The header can hold severalv1=values, so accept if any matches. - Reject if
tis more than 300 seconds from your clock. This stops someone replaying an old event.
Node#
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#
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
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#
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.