Meetle Connect documentation
Add real-time video and voice calling to any application. Your server talks to a small REST API; your app drops in our SDK. You never touch WebRTC, media servers, or scaling.
Base URL — all API requests go to:
https://api.meetle.app/v1
Requests and responses are JSON. All timestamps are ISO-8601 in UTC.
Quickstart
From nothing to a working call in five steps.
1 · Get an API key
Sign in to the console, create a project, then open API keys → Create API key. The secret is shown once — store it somewhere safe (an environment variable, not your source code).
export MEETLE_SECRET=mcs_test_xxxxxxxxxxxxxxxxxxxx
2 · Create a room (server-side)
curl -X POST https://api.meetle.app/v1/rooms \
-H "Authorization: Bearer $MEETLE_SECRET" \
-H "Content-Type: application/json" \
-d '{"name": "support-call-42"}'
// → { "id": "room_9dK2...", "status": "active", ... }
3 · Mint a join token for each participant (server-side)
curl -X POST https://api.meetle.app/v1/rooms/room_9dK2.../tokens \
-H "Authorization: Bearer $MEETLE_SECRET" \
-H "Content-Type: application/json" \
-d '{"identity": "user_42", "name": "Asha"}'
// → { "token": "eyJhbGci...", "ws_url": "wss://media.meetle.app", ... }
4 · Join from your app
Install the SDK, then send the token and ws_url to your client:
npm install @meetle/client
import { MeetleCall } from "@meetle/client";
const call = await MeetleCall.join({ token, wsUrl });
await call.enableCameraAndMic();
call.attachLocalVideo(document.getElementById("me"));
call.attachRemoteMedia(document.getElementById("them"));
5 · Hang up
await call.leave();
That's a complete 1:1 call. Everything below is detail and options.
Authentication
There are two credentials, and keeping them separate is the whole security model.
API secret — server only
Every request to /v1 uses your project's secret as a bearer token:
Authorization: Bearer mcs_live_xxxxxxxxxxxxxxxx
Secrets are prefixed mcs_test_ or mcs_live_ depending on the project's environment, and are stored hashed on our side — we can't show you a secret again after creation, only replace it.
Room token — safe for clients
Room tokens are short-lived JWTs scoped to a single room and identity. They're what you hand to a browser or mobile app. Default lifetime is 15 minutes for /tokens and 1 hour for invites and calls; the maximum is 24 hours.
Core concepts
| Concept | What it is |
|---|---|
| Project | The unit of isolation and billing. Owns its API keys, rooms, webhooks, and usage. Create one per app or per environment. |
| Room | A call session. Participants who hold a token for the same room can see and hear each other. |
| Token | A short-lived credential letting one identity join one room. |
| Participant | Someone currently connected to a room, identified by the identity you chose (usually your own user ID). |
| Call | A higher-level wrapper over a room that adds ringing, accept, reject, and missed — see Calls. |
Rooms
Create a room
POST /v1/rooms
| Field | Type | Default | Notes |
|---|---|---|---|
name | string | auto-generated | Your label. Must be unique among active rooms in the project. |
max_participants | integer | 2 | Group rooms supported — up to 20. Default stays 2 for 1:1. |
empty_timeout_secs | integer | 300 | Auto-end this many seconds after the last participant leaves. |
metadata | object | {} | Your own data (≤ 4 KB), echoed back in webhooks. |
curl -X POST https://api.meetle.app/v1/rooms \
-H "Authorization: Bearer $MEETLE_SECRET" \
-H "Content-Type: application/json" \
-d '{"name": "consult-4821", "metadata": {"booking_id": "bk_77"}}'
Returns 201 with the room object:
{
"id": "room_9dK2xM41xt0jTcHgzyVv",
"name": "consult-4821",
"status": "active",
"participant_count": 0,
"max_participants": 2,
"empty_timeout_secs": 300,
"metadata": { "booking_id": "bk_77" },
"ended_reason": null,
"created_at": "2026-07-22T10:14:02.000Z",
"ended_at": null
}
List rooms
GET /v1/rooms?status=active&limit=20&offset=0
status is active (default) or ended. Returns { "data": [...], "has_more": false }.
Fetch a room
GET /v1/rooms/{room_id}
End a room
DELETE /v1/rooms/{room_id}
Disconnects everyone immediately and marks the room ended. Safe to call twice — the second call simply returns the ended room.
Tokens & invites
Mint a join token
POST /v1/rooms/{room_id}/tokens
| Field | Type | Default | Notes |
|---|---|---|---|
identity | string | required | Your user's ID. Shown to the other participant and reported in webhooks. |
name | string | — | Display name. |
ttl_secs | integer | 900 | Token lifetime, 60 – 86400. |
can_publish | boolean | true | Set false for a listen-only participant. |
can_subscribe | boolean | true | Set false to send only. |
{
"token": "eyJhbGciOiJIUzI1NiJ9...",
"identity": "user_42",
"ws_url": "wss://media.meetle.app",
"expires_at": "2026-07-22T10:29:02.000Z"
}
Mint one token per participant — never share a token between two people.
Create an invite link
POST /v1/rooms/{room_id}/invites
Same fields as tokens (all optional — a guest identity is generated if you omit it), plus a ready-to-share invite_url. Default lifetime is 1 hour. Delivering the link (email, SMS, chat) is up to you.
Participants
List participants
GET /v1/rooms/{room_id}/participants
{ "data": [
{ "identity": "user_42", "name": "Asha", "joined_at": "...", "is_publishing": true }
]}
Remove a participant
DELETE /v1/rooms/{room_id}/participants/{identity}
Disconnects that person immediately. Returns 204. Useful for moderation or ending one side of a call.
Calls
The Calls API adds phone-style semantics on top of rooms: one person rings, the other accepts or declines, and unanswered calls become missed automatically. You get the whole state machine without building it.
┌── accept ──→ active ──── hangup ───→ ended
ringing ──────┼── reject ──→ rejected
├── cancel ──→ ended (reason: canceled)
└── no answer → missed (automatic)
Start a call
POST /v1/calls
| Field | Type | Default | Notes |
|---|---|---|---|
from | string | required | Caller identity. |
to | string | required | Callee identity. |
ring_timeout_secs | integer | 30 | 5 – 120. After this, the call becomes missed. |
ttl_secs | integer | 3600 | Lifetime of the returned token. |
metadata | object | {} | Your own data, echoed in webhooks. |
Returns 201 with the call plus the caller's token — a backing room is created for you:
{
"call": {
"id": "call_7Ab2...", "status": "ringing",
"from": "user_42", "to": "user_88",
"room_id": "room_...", "ring_timeout_secs": 30,
"created_at": "...", "answered_at": null, "ended_at": null,
"ended_reason": null, "duration_secs": null
},
"token": "eyJhbGci...",
"ws_url": "wss://media.meetle.app",
"expires_at": "..."
}
call.ringing webhook at your
server; you push it to the callee's device (FCM, APNs, your socket) because you own your users
and their devices. See the calling recipe.
Accept a call
POST /v1/calls/{call_id}/accept
Moves the call to active and returns the callee's token. Optional body: {"ttl_secs": 3600}.
Reject a call
POST /v1/calls/{call_id}/reject
Moves the call to rejected and tears down the room.
Hang up or cancel
DELETE /v1/calls/{call_id}
Ends an active call (ended_reason: "hangup") or cancels one still ringing (ended_reason: "canceled"). Idempotent.
Fetch / list calls
GET /v1/calls/{call_id}
GET /v1/calls?status=active&limit=20
Filter by ringing, active, rejected, missed, canceled, or ended.
Shareable meeting links
The fastest way to get people into a call — no integration at all. In the console, open Rooms → Start a meeting, pick a size, and copy the link:
https://meetle.app/join/?c=WmZaYWTiurrX
Anyone who opens it lands on a hosted join page, sees a camera preview, enters a name, and joins in the browser. Nothing to install.
The exchange endpoint is public by design (that's what makes a meeting link work) — codes are high-entropy and expire, by default after 7 days:
POST /public/join/{code}
curl -X POST https://meetle.app/public/join/WmZaYWTiurrX \
-H "Content-Type: application/json" \
-d '{"name": "Asha"}'
// → { "token": "eyJ...", "identity": "guest_...", "room_name": "standup",
// "ws_url": "wss://media.meetle.app", "expires_at": "..." }
No API key is needed for this call — possession of the code is the credential. End the room to invalidate the link immediately.
JavaScript SDK
The client SDK wraps all real-time media. Your app only deals with a token and two DOM elements.
npm install @meetle/client
Joining
import { MeetleCall } from "@meetle/client";
// token + wsUrl come from your server
const call = await MeetleCall.join({ token, wsUrl });
// ask for camera + mic and start sending
await call.enableCameraAndMic();
// render video into your own elements
call.attachLocalVideo(document.getElementById("me"));
call.attachRemoteMedia(document.getElementById("them"));
Controls
await call.setMicrophoneEnabled(false); // mute
await call.setCameraEnabled(false); // camera off
await call.leave(); // hang up
call.localIdentity; // "user_42"
call.remoteIdentity; // "user_88" or null
Group rooms
For more than two people, render a tile per participant and attach each by identity:
function render() {
for (const p of call.participants) { // [{ identity, name, isPublishing }]
const tile = getOrCreateTile(p.identity);
call.attachParticipantMedia(p.identity, tile);
}
}
call.on("participantJoined", render);
call.on("participantLeft", render);
call.on("participantUpdated", render); // tracks changed
Events
call.on("participantJoined", (id) => console.log(id, "joined"));
call.on("participantLeft", (id) => console.log(id, "left"));
call.on("reconnecting", () => showSpinner());
call.on("reconnected", () => hideSpinner());
call.on("disconnected", () => goBackToLobby());
| Method | Description |
|---|---|
MeetleCall.join({token, wsUrl}) | Connect to the call. Does not enable devices. |
enableCameraAndMic() | Request permission and start publishing. |
attachLocalVideo(el) | Render your own camera preview (muted). |
attachRemoteMedia(el) | Render the other person's video and audio. |
setMicrophoneEnabled(bool) | Mute / unmute. |
setCameraEnabled(bool) | Camera on / off. |
leave() | Disconnect. |
localhost
during development). Serve your app over HTTPS or the call will fail to get devices.
Recipe: a 1:1 video call
The minimal complete integration — a server endpoint and a page.
Your server
// POST /api/join { roomName, userId } → { token, wsUrl }
app.post("/api/join", async (req, res) => {
const { roomName, userId } = req.body;
const headers = {
Authorization: `Bearer ${process.env.MEETLE_SECRET}`,
"Content-Type": "application/json",
};
// find-or-create the room
let room = await fetch("https://api.meetle.app/v1/rooms", {
method: "POST", headers, body: JSON.stringify({ name: roomName }),
});
let roomId;
if (room.status === 201) {
roomId = (await room.json()).id;
} else if (room.status === 409) { // already exists
const list = await (await fetch(
"https://api.meetle.app/v1/rooms?status=active&limit=100", { headers }
)).json();
roomId = list.data.find((r) => r.name === roomName)?.id;
}
// mint this user's token
const tok = await (await fetch(
`https://api.meetle.app/v1/rooms/${roomId}/tokens`,
{ method: "POST", headers, body: JSON.stringify({ identity: userId }) }
)).json();
res.json({ token: tok.token, wsUrl: tok.ws_url });
});
Your page
const { token, wsUrl } = await (await fetch("/api/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomName: "support-42", userId: "user_42" }),
})).json();
const call = await MeetleCall.join({ token, wsUrl });
await call.enableCameraAndMic();
call.attachLocalVideo(document.getElementById("me"));
call.attachRemoteMedia(document.getElementById("them"));
<div id="me"></div>
<div id="them"></div>
Recipe: ring / accept / reject
Phone-style calling, the way a dating, social, or support app needs it.
1 · Caller starts the call
const res = await fetch("https://api.meetle.app/v1/calls", {
method: "POST", headers,
body: JSON.stringify({ from: "user_42", to: "user_88", ring_timeout_secs: 30 }),
});
const { call, token, ws_url } = await res.json();
// give `token` to the caller's app — it can join and wait
2 · Your server receives call.ringing and notifies the callee
app.post("/meetle/webhooks", (req, res) => {
const event = req.body; // verify the signature first — see below
if (event.type === "call.ringing") {
const { id, from, to } = event.data.call;
sendPushNotification(to, { // your FCM / APNs
title: `${from} is calling`,
data: { callId: id, action: "incoming_call" },
});
}
res.sendStatus(200);
});
3 · Callee accepts (or rejects)
// user tapped Accept → your server calls:
const { call, token, ws_url } = await (await fetch(
`https://api.meetle.app/v1/calls/${callId}/accept`, { method: "POST", headers }
)).json();
// hand `token` to the callee's app → both sides are now in the call
// user tapped Decline instead:
await fetch(`https://api.meetle.app/v1/calls/${callId}/reject`, { method: "POST", headers });
4 · Hanging up
await fetch(`https://api.meetle.app/v1/calls/${callId}`, { method: "DELETE", headers });
If nobody accepts within ring_timeout_secs, the call becomes missed on its own and a call.missed webhook fires — no timer needed on your side.
Webhook endpoints
Register an endpoint
POST /v1/webhooks
curl -X POST https://api.meetle.app/v1/webhooks \
-H "Authorization: Bearer $MEETLE_SECRET" \
-H "Content-Type: application/json" \
-d '{"url": "https://your-app.example/meetle/webhooks"}'
Omit events to receive everything, or pass a subset such as ["call.ringing","call.ended"]. The response includes a signing_secret — shown once, so store it now. You can also manage endpoints in the console under Webhooks.
GET /v1/webhooks
DELETE /v1/webhooks/{webhook_id}
Webhook events
Every delivery is a POST with this envelope:
{
"id": "evt_3kW9...",
"type": "call.ringing",
"created_at": "2026-07-22T10:14:02.000Z",
"project_id": "proj_8xQ...",
"data": { ... }
}
| Event | Fires when |
|---|---|
room.created | A room is created via the API. |
room.ended | A room ends (API call or empty timeout). |
participant.joined | Someone connects to a room. |
participant.left | Someone disconnects. Includes duration_secs. |
call.ringing | A call was started — notify the callee. |
call.accepted | The callee accepted. |
call.rejected | The callee declined. |
call.missed | Nobody answered within the ring timeout. |
call.ended | The call finished or was cancelled. |
room.* events for their internal room —
you get a clean call lifecycle instead of duplicate noise.
Verifying signatures
Each delivery carries a signature header:
Meetle-Signature: t=1784655298,v1=3dc3f1a9...
v1 is an HMAC-SHA256 of "{t}.{raw_request_body}" using your endpoint's signing secret. Always verify before trusting an event, and reject timestamps older than five minutes to prevent replay:
import crypto from "node:crypto";
function verifyMeetle(rawBody, header, signingSecret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("="))
);
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto
.createHmac("sha256", signingSecret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(parts.v1)
);
}
Use the raw request body, not a re-serialized object — re-encoding changes the bytes and the signature won't match. In Express, use express.raw({ type: "application/json" }) on this route.
Retries
We expect a 2xx response within 10 seconds. Failures are retried with exponential backoff (about 1m → 5m → 30m → 2h → 6h) before being dropped. Delivery is at-least-once, so make your handler idempotent — de-duplicate on the event id.
Usage
GET /v1/usage?from=2026-07-01&to=2026-07-31
{
"from": "2026-07-01T00:00:00.000Z",
"to": "2026-07-31T00:00:00.000Z",
"participant_minutes": 1284,
"sessions": 412,
"rooms": 206
}
Participant-minutes is the billable unit: one person in a call for one minute, rounded up per session. A 5-minute 1:1 call is 10 participant-minutes. The same figures appear in the console under Usage, with a daily breakdown.
Errors
Every error uses the same shape:
{
"error": {
"type": "invalid_request",
"code": "room_not_found",
"message": "No room with id room_xxx.",
"request_id": "req_8eRWOH4ijyuPdxy0MMmm"
}
}
| Type | HTTP | Meaning |
|---|---|---|
authentication_error | 401 | Missing, unknown, or revoked API key. |
permission_error | 403 | Key isn't allowed to do this. |
invalid_request | 400 / 404 / 409 | Bad input, missing resource, or a conflict (e.g. duplicate room name). |
quota_exceeded | 402 | Free participant-minutes exhausted for the period. |
rate_limited | 429 | Slow down; see Retry-After. |
internal_error | 500 | Our fault. Retry, and send us the request_id. |
Common codes: missing_api_key, invalid_api_key, room_not_found, room_name_taken, room_ended, call_not_found, call_not_ringing, validation_failed.
Always log request_id — quoting it lets us trace the exact request.
Free tier & limits
Free participant-minutes
Every account gets 20,000 free participant-minutes per month, shared across all projects on the account. A participant-minute is one person in a call for one minute — so a 10-minute 1:1 call uses 20.
Track what's left with:
GET /v1/usage/quota
{ "used": 1284, "limit": 20000, "remaining": 18716,
"period": "monthly", "exceeded": false }
When the allowance runs out, requests that would start new billable media — creating rooms, minting tokens, starting calls, redeeming meeting links — return 402 with code quota_exceeded. Reads keep working, and calls already in progress are never cut off mid-conversation. Email hello@meetle.app to raise your limit.
Rate limits
Separate from the quota, these protect the API from bursts:
| Scope | Limit |
|---|---|
| API requests, per project | 300 / minute |
Meeting-link redemption (/public/join), per IP | 20 / minute |
| Console sign-in / sign-up, per IP | 10 / minute |
Exceeding a limit returns 429 with type rate_limited and a Retry-After header telling you how many seconds to wait.
Going live
- Create a live project in the console and use its
mcs_live_secret in production, keepingmcs_test_for development. - Store secrets in environment variables or a secret manager — never in client code or git.
- Serve your app over HTTPS, or browsers will block camera and microphone access.
- Register a webhook endpoint and verify signatures; make handlers idempotent.
- Mint tokens with the shortest lifetime that fits your flow.
- Handle
reconnecting/disconnectedin your UI so users understand what's happening on a flaky network. - Watch Usage in the console to keep an eye on participant-minutes.
Stuck on something, or need a feature that isn't here yet? Email hello@meetle.app — we answer.