Skip to content

Webhooks

GatherCloud POSTs signed JSON notifications to your HTTPS endpoints as things happen — an event goes live, a recording finishes, a question is asked.

Event types

TypeFires when
event.publishedA draft event is published
event.startedAn event goes live
event.endedAn event ends
participant.joinedA participant joins the live event
participant.leftA participant leaves the live event
broadcast.liveA broadcast phase starts streaming
broadcast.pausedA broadcast is paused
recording.readyA merged recording is available
transcript.readyA transcript is available
clip.readyA requested clip is available
question.createdA Q&A question is posted

Participant presence

participant.joined and participant.left track everyone in a live event — both signed-in attendees and anonymous viewers — which makes them handy for feeding audience analytics:

  • Each carries a participantId that pairs a join with its later leave (use it to compute session duration). For signed-in participants the userId field is also set; for anonymous viewers it's omitted and anonymous is true.
  • They fire per participant, not per connection — a signed-in user opening a second tab does not re-fire participant.joined, and their participant.left only fires once their last connection drops. Each anonymous viewer is counted as its own session.
  • Both are best-effort: a reload or brief network blip can produce a left immediately followed by a fresh joined, and a hard server crash may occasionally drop a left. Treat presence as eventually-consistent rather than an exact ledger.
  • On a large public broadcast these can be high volume (one pair per viewer session). Subscribe only if you consume them, and keep your handler fast.

Register an endpoint

From the dashboard (Webhooks → Add webhook) or via the API:

sh
curl -X POST https://api.gathercloud.dev/v1/webhooks \
  -H "x-api-key: gck_..." \
  -H "content-type: application/json" \
  -d '{
    "url": "https://example.com/hooks/gathercloud",
    "eventTypes": ["event.started", "recording.ready"]
  }'

Omit eventTypes (or pass []) to receive everything. The response includes a signing secret (whsec_…) — it is shown exactly once; store it where your receiver can use it for verification.

  • GET /v1/webhooks — list endpoints (without secrets).
  • DELETE /v1/webhooks/:id — stop deliveries immediately.

Delivery

Each delivery is a POST with:

HeaderContents
x-gc-event-typee.g. recording.ready
x-gc-event-idUUID — dedupe on this; retries reuse the same id
x-gc-signaturet=<unix seconds>,v1=<hex HMAC-SHA256>

The body is the event itself. Every delivery carries the same base fields — id, type, workspaceId, eventId, occurredAt:

json
{
  "id": "6e9c2c4e-…",
  "type": "event.started",
  "workspaceId": "…",
  "eventId": "…",
  "occurredAt": "2026-06-11T18:00:02.114Z"
}

Most types add a few fields of their own. For example participant.joined / participant.left carry the participant session and their event role — a signed-in attendee:

json
{
  "id": "1f0a8b6d-…",
  "type": "participant.joined",
  "workspaceId": "…",
  "eventId": "…",
  "participantId": "a3f1…",
  "userId": "a3f1…",
  "anonymous": false,
  "role": "speaker",
  "occurredAt": "2026-06-11T18:01:09.512Z"
}

…and an anonymous viewer (no userId, anonymous: true):

json
{
  "id": "8c2d4e1a-…",
  "type": "participant.joined",
  "workspaceId": "…",
  "eventId": "…",
  "participantId": "anon:5b7c…",
  "anonymous": true,
  "role": "viewer",
  "occurredAt": "2026-06-11T18:02:44.301Z"
}

Other examples: broadcast.live adds broadcastId and playbackUrl; recording.ready / transcript.ready / clip.ready add an assetId; question.created adds questionId and text. The interactive API reference is the source of truth for each type's full shape.

Respond with any 2xx within 10 seconds. On a 5xx or timeout the delivery is retried with backoff; 4xx responses are treated as rejected and not retried. Deliveries can arrive out of order and (rarely) more than once — make your handler idempotent on x-gc-event-id.

Verifying signatures

The signature is HMAC-SHA256("<t>.<raw body>", secret), hex-encoded. Always verify against the raw request body, before any JSON parsing:

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

function verify(header: string, rawBody: string, secret: string): boolean {
  const { t, v1 } = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=') as [string, string]),
  );
  if (!t || !v1) return false;
  // Reject stale timestamps to prevent replay (5 min window).
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}