> ## Documentation Index
> Fetch the complete documentation index at: https://docs.meetingkit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Signed events for meetings, bots and calendar connections: register an endpoint, verify signatures, handle retries.

MeetingKit sends a signed `POST` to your endpoint for every meeting, bot and calendar
connection event in a workspace, however it was created. Each event carries the current
resource.

## Register an endpoint

Webhook endpoints belong to a workspace. With a key that is an owner or admin of it,
register yours with [`POST /webhook_endpoints`](/api-reference/webhook-endpoints/create-a-webhook-endpoint)
and choose the events it receives:

```bash theme={null}
curl -X POST https://api.meetingkit.com/api/v1/webhook_endpoints \
  -H "Authorization: $MEETINGKIT_API_KEY" \
  -H "Happyscribe-Version: 2026-10-12" \
  -H "Content-Type: application/json" \
  -d '{
    "workspace_id": "wks_01k6a2r9s8x7c2dvq3m5n6p4ab",
    "url": "https://api.acme.com/meetingkit/webhooks",
    "enabled_events": ["meeting.created", "meeting.updated", "meeting.finished"]
  }'
```

Store the `secret` from the response. It starts with `whsec_` and is shown only once;
send your own `secret` instead to use one you already hold.

An endpoint covers every meeting, bot and calendar connection in its workspace, however
it was created. If you run one workspace per customer, each workspace needs its own
endpoint; the URL and the secret can be the same.

Endpoints must be public HTTPS URLs. Redirects are not followed. An endpoint is pinned to
the API version of the request that created it, or to `api_version` if you send one, so
its events keep the shape your code was written against.

## Event types

| Event | Sent when |
| - | - |
| [`bot.recording_started`](/api-reference/bots/bot-recording-started) | The notetaker enters the call and recording starts. |
| [`bot.recording_completed`](/api-reference/bots/bot-recording-completed) | The provider recording is available for processing. |
| [`bot.transcript_ready`](/api-reference/bots/bot-transcript-ready) | The transcript is ready on the bot's meeting. |
| [`bot.summary_ready`](/api-reference/bots/bot-summary-ready) | The summary is ready on the bot's meeting. |
| [`bot.failed`](/api-reference/bots/bot-failed) | The notetaker or transcription flow reaches a terminal failure. |
| [`bot.cancelled`](/api-reference/bots/bot-cancelled) | The bot is cancelled before completion. |
| [`meeting.created`](/api-reference/meetings/meeting-created) | A meeting first appears. |
| [`meeting.updated`](/api-reference/meetings/meeting-updated) | Anything in a meeting changes. |
| [`meeting.finished`](/api-reference/meetings/meeting-finished) | A meeting's results are final. |
| [`calendar_connection.connected`](/api-reference/calendar-connections/calendar-connected) | A member finishes consent through a connect link and their calendar starts syncing. |
| [`calendar_connection.disconnected`](/api-reference/calendar-connections/calendar-disconnected) | A calendar connection is disconnected, through the API, the app, or by removing the member. |
| [`calendar_connection.error`](/api-reference/calendar-connections/calendar-connection-needs-a-reconnect) | A connected calendar stopped syncing; send the member a new connect link. |

Each event has a reference page with the full payload schema, collocated with its
resource's endpoints in the sidebar.

## Event payload

Event metadata sits at the top level; the current resource is under `data.object`:

```json theme={null}
{
  "id": "evt_01k0h4x0n7r2c5m8q9v3b6d1ef",
  "type": "bot.summary_ready",
  "created_at": "2026-07-15T14:32:08Z",
  "data": {
    "object": {
      "id": "bot_01k0h4r9s8x7c2dvq3m5n6p4ab",
      "object": "bot",
      "meeting_id": "mtg_01k0h4r9s8x7c2dvq3m5n6p4ab",
      "status": "completed",
      "…": "…"
    }
  }
}
```

| Field | Type | Description |
| - | - | - |
| `id` | String | Stable typed event ID beginning with `evt_`. Use it to deduplicate retries. |
| `type` | String | One of the event types above. Also sent in the `X-HappyScribe-Event` header. |
| `created_at` | ISO 8601 | When MeetingKit emitted the event. |
| `data.object` | Object | Current representation of the affected resource. |

For `meeting.*` events, `data.object` is the meeting as
[`GET /meetings/{id}`](/api-reference/meetings/get-a-meeting) returns it; for
`calendar_connection.*` events, the calendar connection.

For `bot.*` events, `data.object` is the [bot](/api-reference/bots/object) as
`GET /bots/{id}` returns it, with the [recording choices](/meetingkit/send-bot#how-the-bot-looks-and-records)
that apply to it — including bots sent from a connected calendar or manually from the
product. The bot carries no results: on `bot.transcript_ready` and `bot.summary_ready`,
read [`GET /meetings/{meeting_id}/results`](/meetingkit/results). Webhook payloads never
include a MeetingKit editor or share URL.

## Verify signatures

Each request carries three headers:

| Header | Value |
| - | - |
| `X-HappyScribe-Event` | The event type. |
| `X-HappyScribe-Signature` | `t=<unix_timestamp>,v1=<hex_digest>` |
| `X-Webhook-Signature` | `sha256=<hex_digest>`, for receivers that can't sign the timestamp. |

Both digests are lowercase hex HMAC-SHA256, keyed with the endpoint's full signing
secret, `whsec_` prefix included:

| Header | Signed input | Replay protection |
| - | - | - |
| `X-HappyScribe-Signature` | `<timestamp>.<raw_request_body>` | Reject stale `t` values. |
| `X-Webhook-Signature` | `<raw_request_body>` only | None by itself: always deduplicate on the event `id`. |

## Verification code

Every request carries `X-HappyScribe-Signature: t=<unix_timestamp>,v1=<hex_digest>`.
The digest is an HMAC-SHA256 of `<timestamp>.<raw request body>`, keyed with the full
signing secret, `whsec_` prefix included.

Verify **before** parsing the JSON, on the raw bytes you received, and reject
timestamps older than five minutes:

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import express from 'express';
  import { createHmac, timingSafeEqual } from 'node:crypto';

  const SECRET = process.env.MEETINGKIT_WEBHOOK_SECRET;
  const TOLERANCE_SECONDS = 300;

  function verify(rawBody, header) {
    const timestamp = header?.match(/(?:^|,)t=(\d+)/)?.[1];
    const supplied = header?.match(/(?:^|,)v1=([0-9a-f]+)/)?.[1];
    if (!timestamp || !supplied) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;

    const expected = createHmac('sha256', SECRET).update(`${timestamp}.`).update(rawBody).digest();
    const given = Buffer.from(supplied, 'hex');
    return given.length === expected.length && timingSafeEqual(given, expected);
  }

  const app = express();

  app.post(
    '/webhooks/meetingkit',
    express.raw({ type: 'application/json', limit: '50mb' }),
    (req, res) => {
      if (!verify(req.body, req.get('X-HappyScribe-Signature'))) return res.sendStatus(400);

      const event = JSON.parse(req.body);
      // Store the event durably, then acknowledge. Do the slow work in a background job.
      saveEvent(event);
      res.sendStatus(204);
    },
  );
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import json
  import os
  import re
  import time

  from flask import Flask, request

  SECRET = os.environ["MEETINGKIT_WEBHOOK_SECRET"].encode()
  TOLERANCE_SECONDS = 300

  app = Flask(__name__)


  def verify(raw_body: bytes, header: str | None) -> bool:
      timestamp = re.search(r"(?:^|,)t=(\d+)", header or "")
      supplied = re.search(r"(?:^|,)v1=([0-9a-f]+)", header or "")
      if not timestamp or not supplied:
          return False
      if abs(time.time() - int(timestamp[1])) > TOLERANCE_SECONDS:
          return False

      expected = hmac.new(SECRET, timestamp[1].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, supplied[1])


  @app.post("/webhooks/meetingkit")
  def meetingkit_webhook():
      raw_body = request.get_data()
      if not verify(raw_body, request.headers.get("X-HappyScribe-Signature")):
          return "", 400

      event = json.loads(raw_body)
      # Store the event durably, then acknowledge. Do the slow work in a background job.
      save_event(event)
      return "", 204
  ```
</CodeGroup>

Keep your server's clock in sync.

## Retries and ordering

* **Acknowledge fast.** Return any `2xx` within 10 seconds. Anything else, or a
  timeout, is retried with backoff: up to 21 attempts over about 14 hours, with at
  most an hour between two.
* **Store, then acknowledge.** Write the event somewhere durable before you return
  `2xx`, and process it from there.
* **Deduplicate on the event `id`.** Delivery is at least once. A retry has the same
  `id` and a fresh signature timestamp, so return `2xx` for an `id` you already
  stored.
* **Don't rely on order.** Events for one resource usually arrive in order, but not
  always. Treat an event as a signal: read the resource again (`GET /meetings/{id}`,
  `GET /bots/{id}`) and save what the read returns.
* **Keep a fallback.** Webhooks are the fast path, not the only one. If a bot or a
  meeting you expect to be finished has sent nothing, read it with
  [`GET /bots/{id}`](/meetingkit/results) or
  [`GET /meetings/{id}`](/meetingkit/results#without-webhooks).
* **Recover missed events.** After an outage, list
  [`GET /webhook_events?delivery_status=failed`](/api-reference/webhook-events/list-webhook-events)
  from when it started and process the ones you have not stored, or
  [redeliver](/api-reference/webhook-events/redeliver-a-webhook-event) one to your endpoint.
  [`GET /webhook_events/{id}`](/api-reference/webhook-events/get-a-webhook-event) shows every
  delivery attempt and what your server answered.

## Test locally

MeetingKit can only deliver to public HTTPS URLs. During development, expose your
local receiver with a tunnel such as `ngrok http 8787` or
`cloudflared tunnel --url http://localhost:8787`, and register the tunnel's URL.

Then call [`POST /webhook_endpoints/{id}/test`](/api-reference/webhook-endpoints/send-a-test-event)
to check that your receiver answers `2xx` and verifies the signature: the response carries
the status code it returned. A test event is a sample: its IDs do not exist, so do not
fetch them. To see real events, run the [Quickstart](/meetingkit/quickstart) with the endpoint
registered.

A failed bot's `reason` values are listed in [Failure reasons](/meetingkit/troubleshooting#why-a-bot-failed).

<Note>
  Webhooks don't change MeetingKit email behavior — meeting-summary emails continue to follow the
  existing product and user notification preferences.
</Note>
