MeetsoAPI Docs

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

TransportHTTPS POST, JSON body
Events (v1)transcript.ready, summary.ready (+ webhook.test for self-checks)
SigningHMAC-SHA256 over ${timestamp}.${rawBody}, Meetso-Signature: t=…,v1=…
Replay window5 minutes (reject deliveries with |now - t| > 300s)
Retries5 attempts, exponential backoff: 30s → 90s → 210s → 450s (~13 min total)
Auto-disableEndpoint disables itself after 5 consecutive failed deliveries
Per-org cap25 active endpoints
IdempotencyStable idempotency_key per (event, source resource) — dedupe on your side
ConfigurationDashboard → 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.

  1. Settings → Webhooks → New endpoint
  2. Enter your HTTPS URL (private/loopback/link-local addresses are rejected by an SSRF guard).
  3. Pick the events to subscribe to.
  4. 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

FieldTypeNotes
eventstringEvent name (see below).
delivery_idstringStable id of this exact delivery row. Matches the Meetso-Delivery header byte-for-byte. Replays use a new delivery_id.
idempotency_keystringStable 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_atISO 8601 datetimeWhen 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.
dataobjectEvent-specific. See below.

Event types

transcript.ready

Fires once the meeting transcript is fully diarized and persisted.

FieldTypeNotes
data.organization_idstringYour org.
data.meeting_idstringThe meeting.
data.transcript_idstringThe transcript.
data.modelstringThe diarization + STT model combo.
data.languagestring | nullDetected language code, or null.
data.duration_secnumberFinal transcript duration.
data.speaker_countnumberCount of distinct speaker labels in the transcript.
data.api_urlstringAbsolute URL to fetch the full transcript with your API key.

summary.ready

Fires once the AI meeting summary has been generated and committed.

FieldTypeNotes
data.organization_idstringYour org.
data.meeting_idstringThe meeting.
data.summary_idstringThe AgentRun row id.
data.modelstringThe OpenAI model that produced the summary.
data.content_previewstringFirst 500 chars of the markdown summary.
data.api_urlstringAbsolute 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.

FieldTypeNotes
data.organization_idstringYour org.
data.messagestringHuman-readable test marker.

Headers

Every delivery carries:

HeaderPurpose
Meetso-Signaturet=<unix-seconds>,v1=<hex-hmac-sha256> — see Verifying below.
Meetso-EventThe event name, mirroring body.event. Cheap to dispatch on.
Meetso-DeliveryThe delivery row id, matching body.delivery_id.
Meetso-Idempotency-KeyMatches body.idempotency_key.
User-AgentMeetso-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 returnsWhat we do
2xxDelivered. Endpoint's failure counter resets to 0.
408, 429, any 5xxRetry with exponential backoff (see below).
Any other 4xx (e.g. 400, 401, 403, 404)Mark FAILED permanently — no retries. Misconfiguration on your side.
3xx redirectsTreated as permanent failure. We do not follow webhook redirects. Configure the final URL directly.
Network error / timeoutSame as 5xx — retry. We bound each attempt at a 10-second timeout.

Retry cadence — 5 attempts, exponential (2^n - 1) * 30s:

AttemptWait before this attempt
10
230s
390s
43m 30s
57m 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 disabledisActive 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.

On this page