Guides
Webhooks
Get a signed POST to your server the moment a recording is ready, with links to view and download it — so you can save it or show it without polling.
Set up an endpoint
- Add a route to your server that accepts a JSON
POST— the examples below are complete. - On the dashboard’s Settings page, enter its URL under Webhooks and save. A signing secret (
whsec_…) is made for you; copy it into your server’s environment. - Press Send test event. You get a signed
webhook.testand see what your endpoint answered.
The URL must be https and reachable from the internet — addresses on private networks, loopback and link-local are refused. For local development, use a tunnel such as ngrok or cloudflared.
Events
| Event | Sent when | data |
|---|---|---|
recording.ready | A recording has finished and its file can be played and downloaded. | recording, links |
recording.failed | A recording ended with nothing usable — the recorder failed, or nothing was captured — or one that was waiting for a recorder never got one (everyone left first, or none came free within half an hour). | recording (links is null) |
webhook.test | You pressed Send test event (or called the test endpoint). | message |
A recording that was aborted sends nothing: whoever aborted it already knows. More event types will be added over time, so ignore any type you don’t recognise rather than failing on it.
What we send
POST /webhooks/inlinemeet HTTP/1.1
Content-Type: application/json
User-Agent: InlineMeet-Webhooks/1.0
InlineMeet-Event: recording.ready
InlineMeet-Event-Id: evt_9f2c41d07a6b3e8c15d2a0f4
InlineMeet-Signature: t=1790261266,v1=5f0c1e…
{
"id": "evt_9f2c41d07a6b3e8c15d2a0f4",
"type": "recording.ready",
"createdAt": "2026-09-24T14:47:46.120Z",
"data": {
"recording": {
"id": "66f1c0a4e1b2c3d4e5f60718",
"roomCode": "algebra-101",
"status": "ready",
"hasVideo": true,
"mimeType": "video/mp4",
"startedBy": "teacher-12",
"startedByName": "Alex Morgan",
"startedAt": "2026-09-24T14:00:03.000Z",
"endedAt": "2026-09-24T14:47:40.000Z",
"durationSec": 2857,
"sizeBytes": 123731968,
"labels": { "courseId": "alg-1", "lessonId": "week-3" }
},
"links": {
"view": "https://…/66f1c0a4e1b2c3d4e5f60718.mp4?X-Amz-Expires=86400&…",
"download": "https://…/66f1c0a4e1b2c3d4e5f60718.mp4?response-content-disposition=attachment…",
"expiresAt": "2026-09-25T14:47:46.000Z"
}
}
}| Field | Description |
|---|---|
idstring | Unique per event, and the same on every retry of it. Also in the InlineMeet-Event-Id header. |
typestring | The event type. Also in the InlineMeet-Event header. |
createdAtISO 8601 | When the event happened — not when this attempt was sent. |
data.recordingobject | The recording, exactly as the API returns it — including your labels. |
data.links.viewstring | Plays the file in a browser. |
data.links.downloadstring | Makes the browser (or your job) save the file. |
data.links.expiresAtISO 8601 | When both links stop working: 24 hours after this attempt was sent. Every retry carries fresh links. |
Verify the signature
Every request carries InlineMeet-Signature: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body> using your signing secret. Check it before trusting anything in the body — anyone can POST to a URL — and refuse a t more than five minutes old, so an intercepted request can’t be replayed later. Compute it over the raw bytes you received: a re-serialised JSON body won’t match.
// app/api/webhooks/inlinemeet/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.INLINEMEET_WEBHOOK_SECRET!;
const TOLERANCE_SEC = 5 * 60;
function verify(body: string, header: string | null): boolean {
const parts = Object.fromEntries((header ?? '').split(',').map((kv) => kv.split('=', 2)));
const t = Number(parts.t);
if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) return false;
const expected = createHmac('sha256', SECRET).update(`${t}.${body}`).digest('hex');
return expected.length === parts.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
export async function POST(req: Request) {
const body = await req.text(); // the raw body: verify first, parse after
if (!verify(body, req.headers.get('inlinemeet-signature'))) {
return new Response('Bad signature', { status: 400 });
}
const event = JSON.parse(body);
// A retry carries the same id — handle each event once.
if (await seenEvent(event.id)) return new Response('OK');
if (event.type === 'recording.ready') {
const { recording, links } = event.data;
await saveLessonRecording(recording.labels.lessonId, recording.id);
// Copying a large file takes longer than we wait: hand it to a job.
await jobs.enqueue('copy-recording', { recordingId: recording.id, url: links.download });
}
if (event.type === 'recording.failed') {
await notifyTeacher(event.data.recording.startedBy, 'Your recording could not be saved.');
}
return new Response('OK');
}Respond, and retries
- Answer with any 2xx within 10 seconds. Anything else — another status, a timeout, a redirect — counts as a failure. Do slow work, like copying the file, after you’ve answered.
- Failures are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours — about two days in all — then given up on. Each retry is signed afresh and carries new links.
- Expect the odd duplicate. If your endpoint does the work but answers too slowly, we’ll send it again. Handle each event
idonce. - Order isn’t guaranteed. Use the
recording.statusin the body, or fetch the recording, rather than assuming events arrive in sequence. - The dashboard lists the latest deliveries, with how your endpoint answered and when the next retry is due. Fixing the URL makes pending retries go to the new one.
Save or link a recording
Keep your own copy
Queue a job from the webhook and stream links.download wherever you keep files — your own bucket, a video platform, disk:
import { createWriteStream } from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
// Streams the file to disk without holding it in memory. The link is valid
// for 24 hours; if the job runs later than that, get a fresh one from
// GET /v1/rooms/:code/recordings/:id/file?download=1.
export async function copyRecording({ recordingId, url }: { recordingId: string; url: string }) {
const res = await fetch(url);
if (!res.ok || !res.body) throw new Error(`Download failed: ${res.status}`);
await pipeline(Readable.fromWeb(res.body as any), createWriteStream(`/data/recordings/${recordingId}.mp4`));
}Once you have it, you can delete our copy to stop it counting against your storage.
Link it in your app
Store the recording’s id and roomCode against whatever it belongs to — a lesson, a meeting, a ticket — rather than the links, which expire. When someone presses play, have your server fetch a fresh one from GET /v1/rooms/:code/recordings/:id/file and hand it to a <video> tag. The labels you set when recording started come back in the event, so you can tell which lesson it was for without a lookup.
The links in a webhook work for anyone who has them, for 24 hours. Treat them like a password: don’t log them, and don’t put them in a page more people can see than should watch the recording.
Manage it through the API
Everything on the Settings page is also available with an API key — useful for setting up environments from a script.
Your URL, signing secret and the 20 latest deliveries. url and secret are null when webhooks are off.
curl -X PUT https://api.inlinemeet.com/v1/webhook \
-H "Authorization: Bearer $INLINEMEET_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/webhooks/inlinemeet"}'Returns { url, secret }. An empty url turns webhooks off. The secret is made the first time and kept when the URL changes. A URL we can’t send to gets 400 invalid_webhook_url, saying why.
Sends a webhook.test event now and returns { ok: true, status } or { ok: false, error }. Never retried.
Replaces the signing secret and returns the new one. Everything is signed with it from then on — retries of older events included — so update your server straight away.