WOCOM Meet API — v1
Base URL: https://meet.wocomenterprise.com/api/v1
Everything is JSON in and JSON out. There is no session, no cookie and no CSRF token: every request carries its own credential.
Authentication
Authorization: Bearer wocom_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
A token belongs to a tenant, and every request it makes is confined to that tenant's data. No endpoint takes a tenant id — there is nothing to forge.
Issue one from the server:
php artisan meet:api-token issue \
--tenant=kingston \
--name="PBX bridge" \
--user=corporateservices@wocomja.com
The secret is printed once and stored only as a SHA-256 hash. There is no way to print it again — reissue instead.
php artisan meet:api-token list # prefixes only, never secrets
php artisan meet:api-token revoke --id=<uuid> # takes effect immediately
--user sets the default owner for meetings this token creates. --days=90
expires it. --scopes narrows it (below); omit for full access.
Scopes
| scope | grants |
|---|---|
meetings:read |
list and read meetings, read join links |
meetings:write |
create, update, delete meetings |
join:create |
mint access tokens for participants |
participants:read |
see who is in a live meeting |
participants:write |
mute, admit, remove |
recordings:read |
list, read, download recordings |
recordings:write |
start and stop recording, delete recordings |
users:read |
list users (to resolve meeting owners) |
users:write |
create accounts (provisioning from another system) |
meetings:* covers every verb on meetings. * covers everything.
join:create is deliberately not part of meetings:read. A key that may
list the calendar is not thereby a key that may walk into a call.
Conventions
- Success:
{"data": {...}}. Lists add{"meta": {"page", "per_page", "total", "pages"}}. - Failure:
{"error": {"code": "...", "message": "..."}}. - Validation failure:
422with Laravel's{"message", "errors": {field: [...]}}. - Timestamps are ISO-8601 with an offset, always.
404is used where another tenant's object would otherwise be distinguishable from one that does not exist.409means the request was valid and permitted, but the state of the world would not have it — nobody in the meeting, already recording, egress down.- Rate limit: 120 requests per minute per token.
Endpoints
Service
GET /health |
no auth. {"status":"ok","database":true} |
GET /me |
what this token is, its tenant, and the limits its meetings run under |
GET /users |
users:read. ?q= filters on name and email |
POST /users |
users:write. Create an account — see Provisioning people |
Provisioning people
GET /users users:read
POST /users users:write
POST /users is how a system that already knows who works here — a portal, an
HR feed, TechHub — gives someone a WOCOM Meet account without an admin retyping
them into the console.
curl -X POST https://meet.wocomenterprise.com/api/v1/users \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"name": "Alicia Brown",
"email": "alicia.brown@wocomja.com",
"role": "member",
"extension": "1042",
"timezone": "America/Jamaica"
}'
{
"data": {
"created": true,
"user": {
"id": 41, "name": "Alicia Brown", "email": "alicia.brown@wocomja.com",
"role": "member", "extension": "1042", "timezone": "America/Jamaica",
"disabled": false, "disabled_at": null,
"created_at": "2026-08-17T16:04:11+00:00"
},
"temporary_password": "kX7vqRt2Ldp9Sw4m",
"sign_in_url": "https://meet.wocomenterprise.com/login"
}
}
name and email are required; everything else has a default. role is
member or tenant_admin — platform_admin is not grantable by any key, at
any scope. The tenant is read off the token, so there is no tenant field to send
and none to get wrong.
The password. Omit it and Meet generates one, returns it once in
temporary_password, and keeps only the hash — the same contract as an API key.
Deliver it to the person and let them change it. Send password instead only if
you are migrating a credential you already hold; when you do, the response's
temporary_password is null, because echoing a secret back to the system that
sent it only makes another copy of it.
Retries are safe. The endpoint is idempotent on email:
201 + "created": true |
the account was created by this call |
200 + "created": false |
it already existed here; nothing was changed |
409 email_taken |
the address belongs to an account in another tenant |
Sending the same person twice is a no-op, so a webhook that delivers twice —
and they all do eventually — does not make two accounts or an error to page
somebody about. Nothing on an existing account is updated by a repeat call: a
name an admin corrected by hand stays corrected, and an account somebody
deliberately disabled is not silently re-enabled by a queue replaying last
week's events. Check disabled on the returned user if you need to know.
Accounts are created here; they are not disabled or deleted here. Offboarding belongs to a tenant admin in Admin → Users, where a person shuts the door and can see they shut it.
Meetings
GET /meetings meetings:read
GET /meetings/{code} meetings:read
POST /meetings meetings:write
PATCH /meetings/{code} meetings:write
DELETE /meetings/{code} meetings:write
GET /meetings/{code}/links meetings:read
GET /meetings filters: from, to (ISO-8601, on starts_at), type
(instant|scheduled|personal), q, include_instant=0, per_page, page.
Instant meetings have no starts_at, so a date range would drop them silently.
They are included by default; include_instant=0 opts out.
Create:
curl -X POST https://meet.wocomenterprise.com/api/v1/meetings \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"title": "Q3 Partner Review",
"starts_at": "2026-09-01T14:00:00-05:00",
"duration_minutes": 45,
"lobby_enabled": true,
"pin": "4821",
"owner_email": "corporateservices@wocomja.com",
"guests": ["alex@example.com"]
}'
Omit starts_at for an instant meeting — joinable immediately and indefinitely.
owner_email defaults to the token's own user. On PATCH, only the fields you
send change; setting starts_at: null turns a scheduled meeting instant.
A meeting's pin is never returned. has_pin tells you whether one is set.
DELETE removes the booking and its recordings. Anyone currently in the call
stays in it — cancelling a booking is not the same act as ending a call.
Joining
POST /meetings/{code}/join join:create
Mints a LiveKit access token so your own client connects straight to the SFU, bypassing our UI entirely.
curl -X POST .../meetings/wocom-abc-def/join \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name": "Alex Chen"}'
{"data": {
"token": "eyJhbGciOi...",
"server_url": "wss://sfu.meet.wocomenterprise.com",
"room": "t_019fe..._wocom-abc-def",
"identity": "api:JjZZmwj93SwD",
"waiting": true,
"expires_at": "2026-08-12T00:08:00+00:00"
}}
| field | |
|---|---|
name |
required, shown to everyone in the room |
is_host |
grants moderation. Off unless asked for |
identity |
supply your own for a stable identity. A second connection claiming it evicts the first |
ttl |
seconds, default 900. A ticket to join, not a session length |
waiting |
force into the lobby |
The lobby cannot be bypassed here. If the meeting holds guests, waiting
comes back true whatever you asked for. waiting can add the hold, never
remove it. Hosts are never held.
If what you want is a link to send someone, use POST /meetings/{code}/join-link
below. GET /meetings/{code}/links gives the plain doors — join_url always,
public_url only if the host has minted one — and those always arrive as an
unnamed guest.
Sending someone in named, or as host
POST /meetings/{code}/join-link meetings:write
This is the endpoint for anyone using the hosted rooms. POST /join above
is for building your own client; this is for sending a person to ours with Meet
already knowing who they are. It needs meetings:write, which any key that
books meetings already holds — not join:create.
Two kinds of link, and the difference decides what the person can do:
# A guest, named, with host authority over this one meeting
curl -X POST .../meetings/wocom-abc-def/join-link \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name": "Alex Chen", "host": true}'
# Somebody with an account here — arrives signed in as themselves
curl -X POST .../meetings/wocom-abc-def/join-link \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"email": "corporateservices@wocomja.com"}'
{"data": {
"url": "https://meet.wocomenterprise.com/join/wocom-abc-def?...&signature=...",
"expires_at": "2026-08-17T19:20:27+00:00",
"identity": "hub:otgv6DCu7Ukd",
"as": "guest-host",
"is_host": true,
"pin_required": false
}}
| field | |
|---|---|
name |
required unless email/user_id names an account. What the room calls them |
email / user_id |
somebody with an account in your tenant. They arrive signed in, and are host if they own the meeting or administer the tenant |
host |
guest links only. Host authority over this one meeting — mute, remove, admit, record |
identity |
your own stable id for this person, so files, consents and grants resolve back to one human across visits |
open |
land on board, files, poll or chat instead of at the door |
return_url |
where "leave" sends them, for an embedder. https only |
as comes back guest, guest-host or member, and is_host says plainly
whether this person will be able to run the meeting — so a caller can label its
own menu honestly instead of finding out when the admit button is not there.
A host does not knock at their own door. Whoever holds host authority skips the lobby, by either route above. This is the deadlock an integration hits otherwise: everybody arrives as a guest, the lobby holds them all, and the one person who could open it is standing outside with them. The fix is a host link, not turning the lobby off — a lobby that is off is not a lobby that works.
Links expire in ten minutes. They name a person and cannot be revoked, so
mint one when somebody clicks Join, not when the meeting is booked. A link in
a calendar invite is a link that has expired by the time it is used — put
join_url from GET /links there instead, and let anyone who needs host
authority come through a minted one.
A PIN is not waived. A minted link is a strong claim about who somebody is; it is not a claim that the host meant to skip the second factor they set. The form still appears, with the name already filled in.
Participants
GET /meetings/{code}/participants participants:read
POST /meetings/{code}/participants/{identity}/mute participants:write
POST /meetings/{code}/participants/{identity}/admit participants:write
DELETE /meetings/{code}/participants/{identity} participants:write
Read live from the SFU, which is the only thing that knows who is connected.
409 when nobody is in the meeting.
mute affects audio only, and there is no unmute: a server that can switch
on someone's microphone is a bug with a feature request attached. admit
releases a guest from the lobby.
Somebody is waiting to be let in
A held guest is a connected participant with waiting: true. Their token
grants neither publish nor subscribe — they see and hear nothing until they are
let in — but it does grant canPublishData, which is how they are able to
announce themselves and be listed here at all.
{"data": [
{"identity": "guest:8Kd2mQ", "name": "Alex Chen", "waiting": true, "is_host": false, "tracks": []},
{"identity": "user:019fe...", "name": "Dana Reid", "waiting": false, "is_host": true, "tracks": [...]}
]}
# Who is at the door
curl .../meetings/wocom-abc-def/participants -H "Authorization: Bearer $KEY"
# Let them in
curl -X POST .../meetings/wocom-abc-def/participants/guest:8Kd2mQ/admit \
-H "Authorization: Bearer $KEY"
# Or turn them away — removing a waiting guest is the "deny" half
curl -X DELETE .../meetings/wocom-abc-def/participants/guest:8Kd2mQ \
-H "Authorization: Bearer $KEY"
URL-encode the identity. Colons survive a path segment intact, but identities you supplied yourself may not.
There is no knock event. Nothing is broadcast when somebody starts waiting — not over the data channel, not as a webhook. Our own room page learns two ways at once, and a client that wants to be reliable should do the same:
the SFU's ParticipantConnected, then read waiting off their metadata |
instant, but only fires for arrivals after you connected |
poll GET /meetings/{code}/participants every ~5s |
catches anyone already waiting when the host joined or reloaded |
Either one alone leaves a guest stuck on "waiting to be let in" with no way through and no way to know why. Ours is polled at five seconds, which is fast enough that nobody is left staring, and light enough to leave running.
Clear a request when the person is no longer waiting, not merely when they
disconnect. The list above returns the whole room, so a guest admitted by a
second host stays in it — with waiting: false. Filtering on presence rather
than on waiting leaves a stale "let Alex in" prompt on the first host's
screen, offering to admit somebody who is already in the meeting.
Recordings
GET /meetings/{code}/recording recordings:read what's running now
POST /meetings/{code}/recording/start recordings:write
POST /meetings/{code}/recording/stop recordings:write
GET /recordings recordings:read ?meeting_code= ?status=
GET /recordings/{id} recordings:read
GET /recordings/{id}/download recordings:read
DELETE /recordings/{id} recordings:write
Recording is server-side: egress joins the room, renders it, and writes one MP4
on the server. Recording is included for every tenant; it still needs somebody
in the meeting to record (409).
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. Recordings are deleted after 30 days.
Consent
Every recording carries the answers people gave when they were asked to be in it. Counts on the list, names on the single recording — a page of twelve recordings does not need every participant of every one of them, and a consent record is data to hand over deliberately rather than by default.
{"data": {
"id": "019ffc...",
"consent": {
"asked": 4,
"agreed": 3,
"declined": 1,
"participants": [
{"name": "Dana Reid", "guest": false, "agreed": true, "answered_at": "2026-08-15T14:02:11+00:00"},
{"name": "Alex Chen", "guest": true, "agreed": false, "answered_at": "2026-08-15T14:02:29+00:00"}
]
}
}}
| field | |
|---|---|
asked |
how many answers exist. The host counts — starting a recording is an answer |
agreed / declined |
of those |
participants |
only on GET /recordings/{id}. null on the list, which is not the same as [] |
consent |
null when the caller did not ask for it. "Nobody consented" and "nobody was asked" are different claims |
Rows outlive the recording on purpose. A dispute about whether somebody agreed arrives long after the file was pruned, so the meeting title and the recording's start time are copied onto each answer at the moment it is given.
What the server does with an answer. Starting a recording changes nobody's camera or microphone; the meeting carries on while people decide, and agreeing changes nothing either. A refusal is enforced at the SFU — publishing is revoked, so their media cannot reach the file whatever their client does — and given back when the recording stops. They keep seeing and hearing the meeting throughout: declining to be recorded is not declining to attend.
Changed August 2026. Recordings used to revoke publishing for everyone until they answered, including recordings started through this API. They no longer do. The trade is deliberate and worth stating: between the
startcall and somebody pressing "I do not consent", that person is in the file. It is seconds long, and it is what "the meeting does not stop for the dialog" costs.
There is no consent webhook, and no API endpoint for submitting an answer — see Asking for consent from your own client below, which is required reading if you are not using our room page.
Building your own client
POST /meetings/{code}/join hands you a LiveKit token so your own front end
connects straight to the SFU. Everything below then becomes yours, because none
of our browser code is running.
Messages on the data channel
The server broadcasts JSON, UTF-8 encoded, on LiveKit's reliable data channel.
Decode and switch on type; ignore what you do not handle.
| message | payload | what it means |
|---|---|---|
recording |
{on, from, recording, starter} |
recording started or stopped. recording is the id to answer about; starter is the handle of whoever pressed the button, null when started over this API |
consent |
{identity, agreed, recording} |
somebody answered. identity is a handle — user:<id> or guest:<id> — not the per-join identity in the room |
A handle matches an identity when it is equal to it, or is its prefix followed
by : — one person can be in a room from two tabs as user:7:Ab3xK9 and
user:7:Qq11Ww, and both are the same answer.
Asking for consent from your own client
This is the part that will bite you. The consent dialog belongs to our room
page. A client that ignores the recording message never asks, so nobody
answers — and since nobody is muted for not having answered, everyone is
recorded with an empty consent record and nothing on screen saying so. Until
August 2026 the un-answered were silenced at the SFU, so a client that failed to
ask announced itself within seconds. That safety net is gone.
POST /meetings/{code}/recording/consent exists, but it identifies the person
from our session, never from the request body — a client that could name
itself could consent on somebody else's behalf, which is the one thing a consent
record must never allow. A participant who joined with an API-minted token has
no such session, so that endpoint cannot accept their answer today. If you need
consent recorded for participants on your own client, ask us for a token-scoped
endpoint; do not build a dialog that writes nothing down.
Audio notifications
Three sounds matter, and all of them are yours to play:
| sound | who hears it | when |
|---|---|---|
| the door chime | the host | somebody starts waiting to be let in |
| a knock | the guest | while they are held, so they know the request went somewhere |
| the recording announcement | everyone | a recording starts or stops |
The last one is not decoration. An audible announcement to every participant, alongside the visible banner, is part of how WOCOM Meet meets its obligations under the Jamaica Data Protection Act — a client that shows the recording state silently has removed half of that.
The host's is the one that decides whether a lobby works at all. A banner is only a notification if the host happens to be looking at the top of the page, and a guest can sit in the lobby for the length of a presentation believing they were heard.
Browsers will not let you play either one by default, and the failure is silent. This cost us a real bug, so it is written down rather than left to be rediscovered:
- A page that has never been clicked cannot make a sound. If your client
connects on load — no join button on that page — the document has no user
gesture,
play()rejects, and anAudioContextstays suspended. A host who opens a meeting and waits for someone to arrive is exactly that page, which is exactly when the door chime matters. - Bank the permission early. Keep one
Audioelement and play it muted inside the firstpointerdown/keydownthe page gets, then pause and rewind it. Later plays on that element are allowed. Do it on every gesture until one works, not just the first — a first attempt can still be refused. - Never swallow the rejection. Say on screen that sound is blocked, with a button that provides the gesture, and play the missed sound when it arrives. A host who believes they will be told when somebody arrives, and will not be, is worse off than one who knows the sound is off.
- Route it to the speaker they chose. If you offer a speaker picker, call
setSinkIdon the element. Otherwise it plays out of the system default, and anyone on headphones hears nothing while the console cheerfully reports the clip as playing. RacesetSinkIdagainst a ~300ms timer: it can hang rather than reject when the saved device has been unplugged. - In an iframe, add
allow="autoplay". User activation does not cross an origin boundary, so a cross-origin frame is blocked no matter how much the parent page has been clicked.allow="autoplay; microphone; camera; display-capture"is the full set for an embedded room. - Have a silent fallback. Flash the tab title while somebody is at the door. It needs no permission, it survives a muted tab, and it works in the background — which is where a host waiting for a guest usually has the page.
- Announce a person once, not a state repeatedly. The lobby is polled, so chiming per poll is an alarm rather than a notification. Key what you have already announced by identity, so a second arrival still gets its own sound.
A working implementation of all of this is in the WOCOM Meet room page itself —
open /js/room.js on this host and read the notification sounds section,
along with showKnock() immediately below it.
Errors
| status | when |
|---|---|
401 |
no token, or unknown / revoked / expired. One message for all four — telling them apart tells an attacker they found a real key |
403 |
a valid token without the scope for what it asked for |
404 |
no such object in your tenant |
409 |
valid but impossible right now: no live session, already recording, recording still processing |
422 |
validation |
429 |
over 120/minute |
502 |
the media server could not be reached |
Notes for integrators
- Codes are capabilities. Anyone holding
wocom-abc-defcan reach the join page. Treat them like unlisted URLs, and use a PIN or the lobby when that isn't good enough. - Poll, don't assume. Recording completion and lobby admission are both asynchronous, and nothing is pushed when somebody starts waiting.
- Sound is a permission, not a feature. If you are building your own client, read Audio notifications above before you decide the door chime works — it fails silently, and it fails on exactly the page a host sits waiting on.
- The version is in the path.
/api/v1will not change shape under you; breaking changes arrive as/api/v2.