WOCOM Developer API

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.

Base URL 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.

  1. 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:create

    The 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.

  2. Check it

    curl -H "Authorization: Bearer $WOCOM_KEY" \
         https://example.com/api/v1/me

    Comes back with your tenant, your scopes and the limits a meeting runs under.

  3. 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.

Treat the key as a password. It goes in an environment variable on your server, never in a browser, a mobile app, or anything else a user can read. Anyone holding it can create meetings and admit people to them. If it leaks, have it revoked — revocation is immediate.

Scopes

Ask for the narrowest set that does your job.

ScopeLets you
meetings:readList and read meetings and their links
meetings:writeCreate, change and delete meetings
join:createMint access tokens that put someone in a room
participants:readSee who is in a live meeting
participants:writeMute, ask to unmute, admit and remove people
messages:readRead a meeting's chat transcript
messages:writePost a message into a live meeting
polls:readRead polls and their live results
polls:writeLaunch a poll into a meeting and close it
recordings:readList, read and download recordings
recordings:writeStart and stop recording, delete recordings
users:readList users, to resolve meeting owners
users:writeCreate 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.
  • PATCH changes only the fields you send.
  • Rate limit: 120 requests per minute per key.

Meetings

GET/meetingsmeetings:read
GET/meetings/{code}meetings:read
POST/meetingsmeetings:write
PATCH/meetings/{code}meetings:write
DELETE/meetings/{code}meetings:write
GET/meetings/{code}/linksmeetings:read

Creating

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.

A PIN is never returned. You can set one and clear one, but reading a meeting tells you only 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.

A host does not knock at their own door. Whoever holds host authority skips the lobby. If everyone your system sends arrives as a plain guest, a meeting with a lobby holds all of them — including the person who was supposed to admit the others, who is now outside with them. Mint a host link rather than turning the lobby off.
POST/meetings/{code}/join-linkmeetings:write
POST/meetings/{code}/joinjoin:create
POST /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
nameRequired. What everyone in the room sees.
is_hostGrants moderation — mute, remove, admit, record. Off unless you ask.
identitySupply your own to keep it stable across reconnects. A second connection claiming the same identity evicts the first.
ttlSeconds the token stays usable, default 900. This is a ticket to get in, not a limit on how long they stay.
waitingForce this person into the lobby.
The lobby cannot be bypassed through the API. If the meeting holds guests, the response comes back "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();
Mint tokens on your server, never in the browser. Calling our API from front-end code means shipping your API key to every visitor. Your page asks your backend; your backend asks us.

Participants

GET/meetings/{code}/participantsparticipants:read
POST/meetings/{code}/participants/{identity}/muteparticipants:write
POST/meetings/{code}/participants/{identity}/admitparticipants:write
DELETE/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

GET/meetings/{code}/recordingrecordings:read
POST/meetings/{code}/recording/startrecordings:write
POST/meetings/{code}/recording/stoprecordings:write
POST/meetings/{code}/participants/{identity}/ask-unmuteparticipants:write
GET/meetings/{code}/messagesmessages:read
POST/meetings/{code}/messagesmessages:write
GET/backgroundsany token
GET/meetings/{code}/pollspolls:read
POST/meetings/{code}/pollspolls:write
GET/polls/{id}polls:read
POST/polls/{id}/closepolls:write
GET/recordingsrecordings:read
GET/recordings/{id}recordings:read
GET/recordings/{id}/downloadrecordings:read
DELETE/recordings/{id}recordings:write

Recording 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.

A recording is not ready when 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.
Two URLs, for two different jobs. 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

StatusMeans
401No key, or unknown, revoked or expired. One message for all four — telling them apart would tell an attacker they had found a real key.
403A valid key without the scope for what it asked for.
404No such thing in your tenant. Another tenant's meeting is indistinguishable from one that never existed, deliberately.
409Valid and permitted, but not possible right now: nobody in the meeting, already recording, file still processing.
422Validation. Per-field details in errors.
429Over 120 requests a minute. Back off and retry.
502The 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-def can 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/v1 will not change shape underneath you. Breaking changes arrive as /api/v2, with both running side by side.
Stuck? 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.