meetle Connect

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.

The mental model in one line: your server creates a room and mints a short-lived token; your app joins the call with that token. The API secret never leaves your server.

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.

Never ship the API secret in a browser, mobile app, or public repository. Anyone holding it can create rooms and run up your usage. If one leaks, revoke it in the console (API keys → Revoke) — revocation takes effect immediately.

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

ConceptWhat it is
ProjectThe unit of isolation and billing. Owns its API keys, rooms, webhooks, and usage. Create one per app or per environment.
RoomA call session. Participants who hold a token for the same room can see and hear each other.
TokenA short-lived credential letting one identity join one room.
ParticipantSomeone currently connected to a room, identified by the identity you chose (usually your own user ID).
CallA higher-level wrapper over a room that adds ringing, accept, reject, and missed — see Calls.
Rooms or Calls — which should I use? Use Rooms when both people are already expecting to join (a scheduled consultation, a support session, a meeting link). Use Calls when one person rings another and the other must accept or decline, like a phone call. Calls are built on rooms, so you can move between them later without re-architecting.

Rooms

Create a room

POST /v1/rooms

FieldTypeDefaultNotes
namestringauto-generatedYour label. Must be unique among active rooms in the project.
max_participantsinteger2Group rooms supported — up to 20. Default stays 2 for 1:1.
empty_timeout_secsinteger300Auto-end this many seconds after the last participant leaves.
metadataobject{}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

FieldTypeDefaultNotes
identitystringrequiredYour user's ID. Shown to the other participant and reported in webhooks.
namestringDisplay name.
ttl_secsinteger900Token lifetime, 60 – 86400.
can_publishbooleantrueSet false for a listen-only participant.
can_subscribebooleantrueSet 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

FieldTypeDefaultNotes
fromstringrequiredCaller identity.
tostringrequiredCallee identity.
ring_timeout_secsinteger305 – 120. After this, the call becomes missed.
ttl_secsinteger3600Lifetime of the returned token.
metadataobject{}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": "..."
}
Notifying the callee is your job. We fire a 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.

Why a code and not a token? Each visitor exchanges the code for their own token and identity. A link carrying one shared token would make everybody the same participant, and they would evict one another.

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());
MethodDescription
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.
Browsers only allow camera and microphone access on HTTPS (or 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": { ... }
}
EventFires when
room.createdA room is created via the API.
room.endedA room ends (API call or empty timeout).
participant.joinedSomeone connects to a room.
participant.leftSomeone disconnects. Includes duration_secs.
call.ringingA call was started — notify the callee.
call.acceptedThe callee accepted.
call.rejectedThe callee declined.
call.missedNobody answered within the ring timeout.
call.endedThe call finished or was cancelled.
Calls deliberately do not emit 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"
  }
}
TypeHTTPMeaning
authentication_error401Missing, unknown, or revoked API key.
permission_error403Key isn't allowed to do this.
invalid_request400 / 404 / 409Bad input, missing resource, or a conflict (e.g. duplicate room name).
quota_exceeded402Free participant-minutes exhausted for the period.
rate_limited429Slow down; see Retry-After.
internal_error500Our 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:

ScopeLimit
API requests, per project300 / minute
Meeting-link redemption (/public/join), per IP20 / minute
Console sign-in / sign-up, per IP10 / minute

Exceeding a limit returns 429 with type rate_limited and a Retry-After header telling you how many seconds to wait.

Going live

Stuck on something, or need a feature that isn't here yet? Email hello@meetle.app — we answer.