WOCOM API reference

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: 422 with Laravel's {"message", "errors": {field: [...]}}.
  • Timestamps are ISO-8601 with an offset, always.
  • 404 is used where another tenant's object would otherwise be distinguishable from one that does not exist.
  • 409 means 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 start call 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 an AudioContext stays 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 Audio element and play it muted inside the first pointerdown / keydown the 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 setSinkId on 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. Race setSinkId against 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-def can 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/v1 will not change shape under you; breaking changes arrive as /api/v2.