Webhooks
Receive signed HTTP POSTs when transcripts and summaries are ready, instead of polling.
Meetso pushes events to a URL you control so your systems can react the moment a meeting is processed — no polling loop, no race against your customer's CRM sync.
This page is the canonical reference for outbound webhooks: what events fire, what's in the body, how to verify the signature, and how delivery + retries behave.
At a glance
| Transport | HTTPS POST, JSON body |
| Events (v1) | transcript.ready, summary.ready (+ webhook.test for self-checks) |
| Signing | HMAC-SHA256 over ${timestamp}.${rawBody}, Meetso-Signature: t=…,v1=… |
| Replay window | 5 minutes (reject deliveries with |now - t| > 300s) |
| Retries | 5 attempts, exponential backoff: 30s → 90s → 210s → 450s (~13 min total) |
| Auto-disable | Endpoint disables itself after 5 consecutive failed deliveries |
| Per-org cap | 25 active endpoints |
| Idempotency | Stable idempotency_key per (event, source resource) — dedupe on your side |
| Configuration | Dashboard → Settings → Webhooks (admin-only) |
Registering an endpoint
Webhook endpoints are managed from the Meetso dashboard, not the public API. You'll need Owner or Admin on the organization.
- Settings → Webhooks → New endpoint
- Enter your HTTPS URL (private/loopback/link-local addresses are rejected by an SSRF guard).
- Pick the events to subscribe to.
- Copy the signing secret from the reveal modal — it's shown exactly once. We store the encrypted form; we cannot recover it later. If you lose it, use Edit → Rotate on the endpoint.
Once active, deliveries start firing for every matching event in your organization.
Payload format
Every delivery is a POST with Content-Type: application/json and a
body shaped like:
{
"event": "transcript.ready",
"delivery_id": "ckxyz…",
"idempotency_key": "transcript.ready_ckabc…_cke12…",
"occurred_at": "2026-05-27T14:00:00.000Z",
"api_version": "v1",
"data": {
"organization_id": "ckorg…",
"meeting_id": "ckmtg…",
"transcript_id": "ckabc…",
"model": "pyannote-precision-2+whisper-large-v3-turbo",
"language": null,
"duration_sec": 1842,
"speaker_count": 4,
"api_url": "https://api.meetso.ai/public/v1/transcripts/ckabc…"
}
}The body intentionally carries metadata + a pointer, not the
full transcript text. Use data.api_url (already absolute) with your
public-API bearer key to fetch the full resource. This keeps webhook
bodies small and keeps replay cheap.
Fields common to every event
| Field | Type | Notes |
|---|---|---|
event | string | Event name (see below). |
delivery_id | string | Stable id of this exact delivery row. Matches the Meetso-Delivery header byte-for-byte. Replays use a new delivery_id. |
idempotency_key | string | Stable across retries of the same delivery; stable across pipeline re-runs that re-fire the same logical event. Manual replays append _replay_<n>. Use this on your side for dedupe. |
occurred_at | ISO 8601 datetime | When the event happened (transcript persisted, summary committed). |
api_version | "v1" | Pinned. If we ever ship a v2 payload shape, you'll get a separate v2 registration option in the dashboard with overlap. |
data | object | Event-specific. See below. |
Event types
transcript.ready
Fires once the meeting transcript is fully diarized and persisted.
| Field | Type | Notes |
|---|---|---|
data.organization_id | string | Your org. |
data.meeting_id | string | The meeting. |
data.transcript_id | string | The transcript. |
data.model | string | The diarization + STT model combo. |
data.language | string | null | Detected language code, or null. |
data.duration_sec | number | Final transcript duration. |
data.speaker_count | number | Count of distinct speaker labels in the transcript. |
data.api_url | string | Absolute URL to fetch the full transcript with your API key. |
summary.ready
Fires once the AI meeting summary has been generated and committed.
| Field | Type | Notes |
|---|---|---|
data.organization_id | string | Your org. |
data.meeting_id | string | The meeting. |
data.summary_id | string | The AgentRun row id. |
data.model | string | The OpenAI model that produced the summary. |
data.content_preview | string | First 500 chars of the markdown summary. |
data.api_url | string | Absolute URL to fetch the full meeting (including summary) with your API key. |
webhook.test
Fires when an admin clicks Send test event in the dashboard. Always delivered to the endpoint that triggered it, regardless of event subscriptions — that's the whole point. Use this to validate your verifier before the first real event lands.
| Field | Type | Notes |
|---|---|---|
data.organization_id | string | Your org. |
data.message | string | Human-readable test marker. |
Headers
Every delivery carries:
| Header | Purpose |
|---|---|
Meetso-Signature | t=<unix-seconds>,v1=<hex-hmac-sha256> — see Verifying below. |
Meetso-Event | The event name, mirroring body.event. Cheap to dispatch on. |
Meetso-Delivery | The delivery row id, matching body.delivery_id. |
Meetso-Idempotency-Key | Matches body.idempotency_key. |
User-Agent | Meetso-Webhooks/1.0 |
Verifying the signature
The signature is HMAC-SHA256 over ${timestampSeconds}.${rawRequestBody},
using your endpoint's signing secret as the HMAC key. Verify against the
raw request bytes — parsing JSON and re-serializing will produce a
different string and fail verification.
Node.js (>= 18)
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.MEETSO_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300; // 5 minutes
export function verifyMeetsoWebhook(
signatureHeader: string | undefined,
rawBody: string,
now = Date.now(),
): boolean {
if (!signatureHeader) return false;
// Parse `t=…,v1=…` tolerantly of ordering and whitespace.
let t: number | null = null;
let v1: string | null = null;
for (const part of signatureHeader.split(',')) {
const [k, v] = part.trim().split(/=(.*)/s);
if (k === 't') t = Number.parseInt(v, 10);
else if (k === 'v1') v1 = v;
}
if (t === null || v1 === null) return false;
// Reject deliveries outside the replay window.
const nowSeconds = Math.floor(now / 1000);
if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false;
// Recompute HMAC.
const expected = createHmac('sha256', SECRET)
.update(`${t}.${rawBody}`)
.digest('hex');
// Timing-safe compare. Buffers must be the same length.
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}Express middleware
If you use Express, capture the raw body before any JSON middleware
runs — otherwise req.body is the parsed object and the signature
won't match.
import express from 'express';
import { verifyMeetsoWebhook } from './verify-meetso-webhook';
const app = express();
app.post(
'/webhooks/meetso',
express.raw({ type: 'application/json' }), // raw Buffer, NOT json
(req, res) => {
const ok = verifyMeetsoWebhook(
req.header('Meetso-Signature'),
req.body.toString('utf8'),
);
if (!ok) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// ...dispatch on event.event, dedupe on event.idempotency_key...
res.status(200).send('ok');
},
);Delivery + retries
| Status code your endpoint returns | What we do |
|---|---|
2xx | Delivered. Endpoint's failure counter resets to 0. |
408, 429, any 5xx | Retry with exponential backoff (see below). |
Any other 4xx (e.g. 400, 401, 403, 404) | Mark FAILED permanently — no retries. Misconfiguration on your side. |
3xx redirects | Treated as permanent failure. We do not follow webhook redirects. Configure the final URL directly. |
| Network error / timeout | Same as 5xx — retry. We bound each attempt at a 10-second timeout. |
Retry cadence — 5 attempts, exponential (2^n - 1) * 30s:
| Attempt | Wait before this attempt |
|---|---|
| 1 | 0 |
| 2 | 30s |
| 3 | 90s |
| 4 | 3m 30s |
| 5 | 7m 30s |
Total wall-clock window from first failure to terminal: ~13 minutes.
Auto-disable
If 5 consecutive deliveries reach terminal failure, the endpoint is
automatically disabled — isActive flips to false and
disabledReason is populated with the last failure cause. No more
deliveries fire until you re-enable from the dashboard. Re-enabling
clears the failure counter.
This protects both sides: your servers from a thundering herd if they're recovering from an outage, and our queue from indefinite backlog growth.
Idempotency
idempotency_key is stable across:
- Retries of the same delivery
- Pipeline re-runs that produce the same logical event (e.g. transcript was reprocessed)
idempotency_key is fresh for:
- Manual replays from the dashboard — the key gets a
_replay_<n>suffix so you can distinguish "intended re-send" from "duplicate attempt" if you care.
Recipe: on receipt, store idempotency_key in a unique-indexed
table (or your job-queue's dedup mechanism). If you see the same key
again, return 200 immediately without doing the work again. This
also makes your verifier safe to put behind a load balancer that
might double-deliver.
Testing
Use Send test event on any active endpoint row to fire a
synthetic webhook.test. The delivery row appears in the endpoint's
delivery log within ~200ms (with the same signature scheme and headers
as real events), so you can validate your verifier end-to-end before
the first real meeting.
Inspecting + replaying deliveries
Every endpoint has a deliveries drawer (View deliveries from the
endpoint row) showing the last N attempts: status, response code,
latency, captured request + response headers and body. Failed
deliveries can be replayed — the replay creates a fresh delivery row
with the _replay_<n> suffix described above so the audit trail
stays intact.