Put WOCOM Meet inside your product
Create meetings, hand people a way in, moderate the room and collect the recording — over plain HTTP, from any language. If your application can send a request, it can run meetings.
https://meet.wocomenterprise.com/api/v1
Looking for every field and every status code? Read the full API reference — including recording consent, the meeting data channel and the lobby, for anyone building their own client.
Quick start
Three requests: prove your key works, create a meeting, get someone in.
-
Get a key
Keys are issued by an operator on the server. Ask whoever runs your WOCOM Meet instance, and tell them what the integration is for and which scopes it needs — they run:
php artisan meet:api-token issue \ --tenant=your-tenant \ --name="Billing portal" \ --user=you@example.com \ --scopes=meetings:* --scopes=join:createThe secret is shown once and stored hashed. Nobody can print it again, including the person who issued it — if it is lost, it is reissued.
-
Check it
curl -H "Authorization: Bearer $WOCOM_KEY" \ https://example.com/api/v1/meComes back with your tenant, your scopes and the limits a meeting runs under.
-
Create a meeting and get a link
curl -X POST https://example.com/api/v1/meetings \ -H "Authorization: Bearer $WOCOM_KEY" \ -H "Content-Type: application/json" \ -d '{"title": "Kickoff call"}'The response carries a
join_url. Send it to whoever should attend — that is the whole integration for most people. Read on if you want them inside your interface instead.
Authentication
Every request carries a bearer token. There is no session, no cookie and no CSRF token to fetch first.
Authorization: Bearer wocom_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
A key belongs to a tenant, and everything it can see or change belongs to that tenant. No endpoint takes a tenant id — there is nothing to pass, and therefore nothing to tamper with.
Scopes
Ask for the narrowest set that does your job.
| Scope | Lets you |
|---|---|
meetings:read | List and read meetings and their links |
meetings:write | Create, change and delete meetings |
join:create | Mint access tokens that put someone in a room |
participants:read | See who is in a live meeting |
participants:write | Mute, ask to unmute, admit and remove people |
messages:read | Read a meeting's chat transcript |
messages:write | Post a message into a live meeting |
polls:read | Read polls and their live results |
polls:write | Launch a poll into a meeting and close it |
recordings:read | List, read and download recordings |
recordings:write | Start and stop recording, delete recordings |
users:read | List users, to resolve meeting owners |
users:write | Create accounts, so people provisioned in another system can sign in |
meetings:* covers every verb on meetings.
* covers everything.
join:create is separate from meetings:read on purpose.
A key that lists the calendar for a dashboard is not thereby a key that
can walk into the calls on it. If you only display schedules, do not ask
for it.
Request & response shape
Success is always wrapped in data. Lists add meta.
{
"data": [ { "code": "wocom-abc-def", "title": "Kickoff call" } ],
"meta": { "page": 1, "per_page": 25, "total": 1, "pages": 1 }
}
Failure is always wrapped in error.
{
"error": { "code": "insufficient_scope",
"message": "This token does not carry the 'meetings:write' scope." }
}
- Timestamps are ISO-8601 with an offset, always. Send them the same way.
- Meetings are addressed by their code (
wocom-abc-def), not their id. PATCHchanges only the fields you send.- Rate limit: 120 requests per minute per key.
Meetings
/meetingsmeetings:read/meetings/{code}meetings:read/meetingsmeetings:write/meetings/{code}meetings:write/meetings/{code}meetings:write/meetings/{code}/linksmeetings:readCreating
POST /api/v1/meetings
{
"title": "Q3 Partner Review",
"starts_at": "2026-09-01T14:00:00-05:00",
"duration_minutes": 45,
"lobby_enabled": true,
"pin": "4821",
"owner_email": "you@example.com",
"guests": ["alex@example.com"]
}
Leave out starts_at for an instant meeting —
one that is joinable now and stays joinable. That is usually what you
want when a call is already happening and you are making a room for it.
Everything else is optional; owner_email defaults to the
user your key was issued for.
On PATCH, sending "starts_at": null turns a
scheduled meeting back into an instant one.
has_pin. A read-only
key must not become a way to collect the second factor.
Filtering the list
from and to (ISO-8601, against
starts_at), type, q,
per_page, page.
Instant meetings have no start time, so a date range would drop them
without saying so. They are included by default — pass
include_instant=0 if you genuinely only want the diary.
Deleting
Removes the booking and its recordings. Anyone currently in the call stays in it — cancelling a booking and hanging up on people are different acts, and this is the first one.
Joining a meeting
Three ways in, and the right one depends on who is arriving.
Send them a link
join_url from any meeting response, or
GET /meetings/{code}/links. They land in WOCOM Meet,
give a name, and join — as a guest.
Fine for people you are inviting in.
Send them in named, or as host
POST /meetings/{code}/join-link mints a signed link
that already knows who is arriving. Pass host: true
for a guest who should run the meeting, or email for
somebody with an account here — they arrive signed in.
Needs meetings:write, not join:create.
This is the one an integration usually wants.
Put the room in your app
POST /meetings/{code}/join mints a LiveKit access
token. Your own front end connects straight to the media server
and our interface is never involved.
A build, not a setting. Only if you are rendering your own video client.
/meetings/{code}/join-linkmeetings:write/meetings/{code}/joinjoin:createPOST /api/v1/meetings/wocom-abc-def/join
{ "name": "Alex Chen" }
→ {
"data": {
"token": "eyJhbGciOi...",
"server_url": "wss://sfu.example.com",
"room": "t_019fe..._wocom-abc-def",
"identity": "api:JjZZmwj93SwD",
"is_host": false,
"waiting": false,
"expires_at": "2026-08-12T00:08:00+00:00"
}
}
| Field | |
|---|---|
name | Required. What everyone in the room sees. |
is_host | Grants moderation — mute, remove, admit, record. Off unless you ask. |
identity | Supply your own to keep it stable across reconnects. A second connection claiming the same identity evicts the first. |
ttl | Seconds the token stays usable, default 900. This is a ticket to get in, not a limit on how long they stay. |
waiting | Force this person into the lobby. |
"waiting": true whatever you asked for. The flag can add the
hold; it can never remove it. An integration must not be able to walk
someone past a door the host closed. Hosts are never held.
Using the token in a browser
import { Room } from 'livekit-client';
// Minted by YOUR server, which is where the API key lives.
const { token, server_url } = await fetch('/your-backend/meeting-token')
.then(r => r.json());
const room = new Room();
await room.connect(server_url, token);
await room.localParticipant.enableCameraAndMicrophone();
Participants
/meetings/{code}/participantsparticipants:read/meetings/{code}/participants/{identity}/muteparticipants:write/meetings/{code}/participants/{identity}/admitparticipants:write/meetings/{code}/participants/{identity}participants:write
Read live from the media server, which is the only thing that knows who
is actually connected. If nobody is in the meeting you get
409 — that is a state, not a mistake.
admit releases someone from the lobby.
mute affects audio only, and there is no unmute:
a server that can switch on a microphone in a room somebody is sitting in
is not a feature we are willing to ship.
Recordings
/meetings/{code}/recordingrecordings:read/meetings/{code}/recording/startrecordings:write/meetings/{code}/recording/stoprecordings:write/meetings/{code}/participants/{identity}/ask-unmuteparticipants:write/meetings/{code}/messagesmessages:read/meetings/{code}/messagesmessages:write/backgroundsany token/meetings/{code}/pollspolls:read/meetings/{code}/pollspolls:write/polls/{id}polls:read/polls/{id}/closepolls:write/recordingsrecordings:read/recordings/{id}recordings:read/recordings/{id}/downloadrecordings:read/recordings/{id}recordings:writeRecording happens on the server: a recorder joins the room, renders it, and writes one file. It does not depend on anyone's laptop staying awake.
stop returns.
Encoding continues after the room closes. Poll
GET /recordings/{id} until playable is true,
then use download_url. Treating the stop response as "done"
is the single most common integration bug here.
download_url is authenticated with your API key — fetch it
server-side, send the key with it. watch_url is signed
instead, so it needs no key and no header: drop it straight into a
<video src> or a link and the browser plays it. Use
that one to put a play button in your own interface. It expires with the
recording, and anyone holding the URL can watch — treat it as the
recording itself rather than as a pointer to it.
Recording is included for every tenant. Recordings are deleted after 30 days.
Errors
| Status | Means |
|---|---|
401 | No key, or unknown, revoked or expired. One message for all four — telling them apart would tell an attacker they had found a real key. |
403 | A valid key without the scope for what it asked for. |
404 | No such thing in your tenant. Another tenant's meeting is indistinguishable from one that never existed, deliberately. |
409 | Valid and permitted, but not possible right now: nobody in the meeting, already recording, file still processing. |
422 | Validation. Per-field details in errors. |
429 | Over 120 requests a minute. Back off and retry. |
502 | The media server could not be reached. Retry; the meeting itself is usually fine. |
Recipes
Click-to-meet from a phone system or CRM
Make a room for a call that is already happening, and hand both sides a link.
$meeting = $http->post("$base/meetings", [
'title' => "Call with {$customer->name}",
'mute_on_entry' => false,
])['data'];
// Agent goes straight in; customer gets the link by SMS.
$sms->send($customer->mobile, "Join the video call: {$meeting['join_url']}");
An embedded room, with your own controls
// On your server — the key never leaves it.
$access = $http->post("$base/meetings/{$code}/join", [
'name' => $user->name,
'identity' => "crm-user-{$user->id}", // stable across reloads
'is_host' => $user->isAgent(),
'ttl' => 300,
])['data'];
return response()->json([
'token' => $access['token'],
'server_url' => $access['server_url'],
]);
Record a call and file it against the customer
$http->post("$base/meetings/{$code}/recording/start");
// ... the meeting happens ...
$http->post("$base/meetings/{$code}/recording/stop");
// Encoding runs on after the room closes, so poll rather than assume.
do {
sleep(15);
$rec = $http->get("$base/recordings", ['meeting_code' => $code])['data'][0];
} while (! $rec['playable']);
$file = $http->getRaw($rec['download_url']); // send the key with this too
A staffed waiting room
// Meeting created with lobby_enabled: true.
foreach ($http->get("$base/meetings/{$code}/participants")['data'] as $p) {
if ($p['waiting'] && $this->isExpected($p['name'])) {
$http->post("$base/meetings/{$code}/participants/{$p['identity']}/admit");
}
}
Rules of the road
-
Meeting codes are capabilities. Anyone holding
wocom-abc-defcan reach the join page. Treat them like unlisted URLs — and when that is not good enough, set a PIN or turn the lobby on. - Poll for anything asynchronous. Recording completion and lobby admission both finish after the request that started them.
- Store the meeting code, not the join URL. The code is the identity; URLs can change shape.
- Handle 409 as normal life. "Nobody is in the meeting yet" is the usual answer to half these endpoints, not an error to alert on.
-
The version is in the path.
/api/v1will not change shape underneath you. Breaking changes arrive as/api/v2, with both running side by side.
GET /api/v1/me answers most
questions — it tells you which tenant your key belongs to, which scopes it
carries and the limits its meetings run under. A surprising number of integration bugs
are a key pointed at the wrong tenant.