Skip to content

Running a meeting from your own application

This is everything your application does once it can prove who it is. Create a meeting, invite people, get each of them a join link, run the meeting, end it, receive the event, and read back what the meeting produced.

You need a credential first. Authenticating your application is that document; every request below carries the token it shows you how to sign, in an Authorization: Bearer header. Nothing below uses a browser session or a cookie.

A call needing a scope your token does not carry is refused with 403 scope_not_granted, and the refusal names the scope that was wanted.

You create no accounts in this platform. Not for the organiser, not for the invitees, not for anyone. The people on your meetings are your users, and you name them here the way you already know them — a name and an email address — each carrying your own identifier for them. Nobody signs up, nobody is provisioned, and there is no user list of ours to keep in step with yours. If your product has fifty thousand users, this platform holds none of them.

Where somebody genuinely does hold an account here — a colleague of yours who administers the workspace — you can name them instead, and that is a choice rather than a requirement.


The whole journey in one page

# You want to Call Scope
1 create a meeting and invite everyone POST /v1/meetings meetings:write
2 add somebody to a meeting you already made POST /v1/meetings/{id}/participants meetings:write
3 see who is already on it GET /v1/meetings/{id}/participants meetings:read
4 get one person a way in POST /v1/meetings/{id}/join-tokens tokens:mint
5 read it back, list, edit GET/PATCH /v1/meetings… meetings:read / :write
6 call it off POST /v1/meetings/{id}/cancel meetings:write
7 say it is over POST /v1/meetings/{id}/end meetings:write
8 be told it is over POST /v1/webhooks once, then your endpoint receives webhooks:manage
9 fetch what it produced GET /v1/meetings/{id}/artefacts artefacts:read
10 see what happened GET /v1/audit-events audit:read

A subject is one of two shapes, everywhere one is accepted — creating a meeting, adding a participant, minting a join token:

{"type": "user",  "user_id": "0193…"}
{"type": "guest", "name": "Ada Byron", "email": "ada@example.com"}

A user names a member of your tenant and carries their identity. A guest names somebody who has none. Every example below uses guest; user takes the same place in the same member.


1. Create the meeting and its invitees, in one call

curl -sS -X POST https://<host>/v1/meetings \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Quarterly review",
    "vertical": "meeting",
    "scheduled_at": "2026-09-05T14:00:00Z",
    "timezone": "Europe/London",
    "duration_minutes": 45,
    "lobby": true,
    "organiser": {"type": "guest", "name": "Rosa Kaur", "email": "rosa@yourcompany.example"},
    "organiser_external_ref": "your-user-88121",
    "participants": [
      {"subject": {"type": "guest", "name": "Ada Byron", "email": "ada@example.com"}, "external_ref": "your-user-4471"},
      {"subject": {"type": "guest", "name": "Ivan Petrov", "email": "ivan@example.com"}, "external_ref": "your-user-9002"}
    ]
  }'

201, and the body carries the occurrence and every participant you just created:

{
  "id": "0193…", "title": "Quarterly review", "status": "scheduled",
  "sequence": 0, "scheduled_at": "2026-09-05T14:00:00Z",
  "timezone": "Europe/London", "duration_minutes": 45, "lobby": true,
  "capacity": 20, "occurrences": 1,
  "entry_url": "https://<host>/m/0193…",
  "organiser": {"type": "guest", "user_id": null, "name": "Rosa Kaur",
                "email": "rosa@yourcompany.example", "external_ref": "your-user-88121"},
  "participants": [
    {"id": "0193…", "meeting_id": "0193…", "name": "…", "meeting_role": "participant",
     "rsvp": "pending", "external_ref": "your-user-4471"},
    …
  ]
}

The body, member by member

Member Required Value
title yes up to 300 characters
vertical yes meeting, conference, webinar or learning. It fixes the meeting's capacity and which features the room offers, and a vertical your tenant is not entitled to is 403 vertical_not_licensed.
scheduled_at yes an instant, ISO 8601
timezone yes an IANA name, such as Europe/London. The occurrence is scheduled in it, so a series survives a daylight-saving change.
duration_minutes yes a positive integer
organiser yes, for an application who owns the meeting: {"type": "guest", "name": …, "email": …} or {"type": "user", "user_id": …}. A guest organiser needs no account here.
organiser_external_ref no your own identifier for the organiser
description no up to 5000 characters
lobby no true by default. With a lobby, somebody who is not on the roster waits to be admitted by a moderator; without one, they enter directly.
registration no {"enabled": false, "approval": "auto"} by default. With enabled: true, only registered participants are admitted and anyone else is 403 registration_required. approval is auto or manual.
recurrence no see below
participants no up to 100 entries; each entry is described in its own table below
organiser_user_id no superseded by organiser; still works, still means a member of your tenant. Sending both is 422.

A participant, member by member

Each entry in participants — and the whole body of POST /v1/meetings/{id}/participants, which takes exactly the same shape.

Member Required Value
subject yes who they are: {"type": "guest", "name": …, "email": …} for one of your own people, or {"type": "user", "user_id": …} for a member of your tenant
meeting_role no organiser, moderator or participant. Defaults to participant.
external_ref no your identifier for this person, carried through untouched. Up to 200 characters, and no two people on one meeting may share one.

Anything else is refused, by name, with a JSON Pointer to it. There is no member this API accepts and ignores.

capacity is read-only. It appears in the response and is a property of the vertical, not something you set: meeting admits 20, learning 100, conference and webinar 500. A meeting that fills answers 409 capacity_reached, and the way to a larger room is a different vertical.

entry_url is the link you send people. It is the one door: whoever opens it identifies themselves there and is issued their own token. It is stable for the meeting's whole life, carries no credential, and is safe in your own invitation email, your calendar entry, or your application's interface.

It is not the same as the join_url returned by POST /v1/meetings/{id}/join-tokens. That one carries a signed token for one named person and expires in ten minutes; forwarding it hands somebody else that person's way in. If you are unsure which you want, you want entry_url.

It is one transaction. The meeting and every invitee commit together or none of them does. If one invitee is wrong, nothing is created — not the meeting, not the other invitees, not an invitation email.

Refusals name the invitee by position. A 422 carries an errors array whose pointers are RFC 6901 JSON Pointers into your request body:

{"code": "validation_failed", "errors": [
  {"pointer": "/participants/2/subject/user_id", "code": "invalid",
   "message": "This user is not an active member of this tenant."}
]}

Every bad entry is reported, not just the first, so fixing a list of fifty does not take fifty round trips.

The organiser can be one of your users, not one of ours

Your application is not a person, so it has to say who owns each meeting. It does not have to create an account here first.

"organiser": {"type": "guest", "name": "Rosa Kaur", "email": "rosa@yourcompany.example"}

That person is the organiser in full: the meeting's owner, the calendar ORGANIZER, and the address the invitation comes from. No account, no membership, nothing to set up. It is the same subject shape you already use for participants and for join links.

Where the organiser genuinely is one of ours, name them instead:

"organiser": {"type": "user", "user_id": "0193..."}

Omit the member entirely and you get 422 organiser_required — your application named nobody at all. type: user naming somebody who is not an active member of your tenant is 422 organiser_not_member; a guest organiser cannot get that refusal, because there is nothing to be a member of.

A signed-in user calling the same endpoint may omit the organiser and becomes the organiser themselves. Naming someone else needs the tenant_admin role — that rule is about one person scheduling in another person's name, and it does not apply to your application.

organiser_user_id still works exactly as it did and means organiser: {"type": "user", "user_id": …}. Sending both is 422: they name the same thing, and we would rather refuse than guess which you meant.

Your own identifier on every person

Every participant, and the organiser, can carry external_ref — your identifier for that person in your system.

{"subject": {"type": "guest", "name": "Ada Byron", "email": "ada@example.com"},
 "external_ref": "your-user-4471"}

We never parse it, never validate it, never assume a format, and never trim it. It comes back byte for byte on every read, it is in the audit trail, and it rides every event that names a participant — so you can match what we tell you to your own records without keeping a table of our identifiers.

Two rules, and only two:

Recurring meetings

Add recurrence. Every occurrence is its own resource with its own id, its own participants and its own join links. The occurrence is the isolation boundary everywhere in this platform: standing on one is not standing on another.

"recurrence": {"freq": "weekly", "interval": 1, "by_day": ["tu"], "count": 8}

Exactly one of until / count. freq is daily, weekly or monthly. That closed set is the supported subset, and anything outside it is 422 rule_outside_subset rather than a silently narrower meaning.

Your invitees are attached to every materialised occurrence, and are mailed once, for the first. occurrences in the response says how many were created; GET /v1/meetings?series_id=… lists them.

At most 100 invitees per call

Invite the rest with POST /v1/meetings/{id}/participants, whose whole body is one entry from the table above — subject, meeting_role and external_ref, with the same meanings.


2. Idempotency-Key

It is required on every creating POST, and this platform stores it with the response it produced.

Stored keys answer replays for 24 hours on this deployment and then expire.

The safe pattern for a network timeout is to retry with the same key. You either get the meeting you already made, or you make it now.


curl -sS -X POST https://<host>/v1/meetings/$MEETING/join-tokens \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"subject": {"type": "guest", "name": "Ada Byron", "email": "ada@example.com"}}'
{"token": "eyJ…", "join_url": "https://meet…/0193…?jwt=eyJ…",
 "role": "participant", "meeting_id": "0193…", "participant_id": "0193…",
 "expires_at": "2026-09-05T14:10:00Z"}

Seven rules govern this call.


4. Read, list, edit, cancel, end

GET    /v1/meetings                      list (filter: from, to, status, vertical, series_id)
GET    /v1/meetings/{id}                 one occurrence
GET    /v1/meetings/{id}/ics             the calendar payload
GET    /v1/meetings/{id}/participants    who is on it
PATCH  /v1/meetings/{id}                 edit — scheduled state only
POST   /v1/meetings/{id}/cancel          call it off; participants are notified
POST   /v1/meetings/{id}/end             it is over

Participants have their own four:

GET    /v1/meetings/{id}/participants                     who is on it
POST   /v1/meetings/{id}/participants                     invite one more
PATCH  /v1/meetings/{id}/participants/{participantId}     change their meeting role
DELETE /v1/meetings/{id}/participants/{participantId}     withdraw the invitation

participantId is the id of a row in the roster, which is what the create response and GET …/participants return. Inviting somebody already on the occurrence is 409 participant_exists; change their role with the PATCH rather than inviting them again.

Sending an external_ref that another person on the same meeting already carries is a different refusal — 409 external_ref_taken. The two are not the same and the remedy is not the same: participant_exists means the person is already invited and there is nothing to do, while external_ref_taken means the person is not invited and your reference belongs to somebody else.

A fifth acts on a visitor:

POST /v1/meetings/{id}/participants/{participantId}/promote     tokens:mint

A visitor is minted by asking for "role": "visitor" on the join-token call. Promoting one supersedes their visitor token with a participant token. A participant holding no live visitor token is 409 not_promotable, a vertical that admits no visitors is 403 visitor_tier_not_offered, and promoting without moderator authority on the occurrence is 403 promotion_not_permitted.

The closed sets a response carries.

Member Values
status on a meeting scheduled, live, completed, cancelled, expired. The ?status= filter takes the same five.
meeting_status, carried by every not_joinable_* refusal the same five
rsvp on a participant pending, accepted, declined, tentative
meeting_role on a participant organiser, moderator, participant
role in a join-token response moderator, participant, visitor — what the token grants, which is not the same thing as meeting_role, the person's standing on the occurrence
data.ended_by on scheduling.meeting_ended operation where somebody ended it, window where the platform closed it after its window passed
vertical meeting, conference, webinar, learning

sequence is load-bearing. It is the RFC 5545 SEQUENCE for the occurrence, and it advances on every material change. Calendar clients discard an update whose sequence has not advanced, so if you mirror these meetings into a calendar of your own, this is the field that tells you your copy is stale.

Cancel is a transition, never a delete. The occurrence stays, with status: cancelled, because it is the anchor for its audit trail and anything it recorded. Participants get a METHOD:CANCEL calendar notice with the incremented sequence.

?scope= on edit and cancel takes this | this_and_future | all for a recurring series. A value outside that set is refused with 422 rather than quietly read as this.

A link already issued for a cancelled meeting. No new token is minted (409 not_joinable_cancelled), and a token already in someone's hands stays valid until it expires. If that matters to you, cancel and then wait out the ten-minute token lifetime before assuming nobody can enter.

Four codes cover the four reasons a mint is refused.

Code What it means Carries
not_joinable_yet the window has not opened window_opens_at, window_closes_at
not_joinable_window_closed still scheduled, past its end window_closes_at
not_joinable_cancelled cancelled —
not_joinable_finished completed or expired —

All four also carry meeting_status. The window opens ten minutes before the start on this deployment and closes at the occurrence's end; a live occurrence is joinable throughout.

not_cancellable and not_endable are different codes. A live meeting is ended; a completed, expired or already-cancelled one is finished. One code for both would tell you neither.


5. Be told the meeting ended

POST   /v1/webhooks                                            register an endpoint
GET    /v1/webhooks                                            list them
PATCH  /v1/webhooks/{id}                                       change events, description, status
DELETE /v1/webhooks/{id}                                       remove one
POST   /v1/webhooks/{id}/rotate-secret                         new signing secret
GET    /v1/webhooks/{id}/deliveries                            what was sent, and what happened
POST   /v1/webhooks/{id}/deliveries/{delivery}/redeliver       send one again

All of them need webhooks:manage, or a tenant_admin session.

Register an endpoint

curl -sS -X POST https://<host>/v1/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/svc",
       "events": ["scheduling.meeting_ended", "artefact.registered"]}'
Member Required Value
url yes https://, on a name that resolves publicly, on port 443 or 8443. No other port is accepted.
events yes at least one exact event name or family prefix
description no up to 300 characters, for your own reference

The 201 carries the signing secret once. Store it now; no endpoint returns it again. What you can read afterwards is secret_fingerprint, the first eight hex of its sha256 — enough to answer "is this the secret the deployment holds?" and not enough to sign anything. If the secret is lost, POST /v1/webhooks/{id}/rotate-secret issues a new one.

Subscribe with an exact event name or a family prefix. A prefix is an event name truncated at a dot, with the dot kept and nothing after it: scheduling. takes every event whose name begins with it, and artefact. and consent. do the same for theirs. There is no wildcard character, and scheduling without the dot is not a prefix. An entry that matches no event this deployment emits is refused at registration with 422 event_unknown and its index, so that a typo cannot become a subscription that never fires. The exact names are in GET /v1/integrator/discovery under webhooks.events; the prefixes are formed from them by this rule.

Your address must be https://, on port 443 or 8443, on a public name. Loopback, private ranges, link-local and the cloud metadata address are refused, as is any other port. The check runs again at delivery time, after DNS, so a name that starts resolving privately later becomes a delivery failure rather than a registration refusal.

What arrives

POST https://example.com/svc
X-Svc-Signature: t=1788091200,v1=aGVsbG8…
X-Svc-Event: scheduling.meeting_ended
X-Svc-Event-Id: 0193…
X-Svc-Sequence: 41
X-Svc-Delivery-Attempt: 1
Content-Type: application/json
{
  "id": "0193…",
  "event": "scheduling.meeting_ended",
  "sequence": 41,
  "occurred_at": "2026-09-05T14:47:03.118000Z",
  "tenant_id": "0193…",
  "correlation_id": "0193…",
  "data": {
    "meeting_id": "0193…", "series_id": null,
    "started_at": "2026-09-05T14:02:11Z",
    "ended_at": "2026-09-05T14:47:03Z",
    "ended_by": "operation"
  }
}

Payloads are thin: identifiers and the few members that make the event actionable, never a snapshot of the meeting. Fetch current state with your own credential when you need it.

Verify the signature

v1 is the base64 of an HMAC-SHA256 over <t>.<raw request body>, keyed with your endpoint secret.

import base64, hashlib, hmac, time

def verify(secret: str, raw_body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(","))
    if "t" not in parts or "v1" not in parts:
        return False                       # no v1 signature to check
    if abs(time.time() - int(parts["t"])) > tolerance:
        return False                       # bounds replay
    expected = base64.b64encode(
        hmac.new(secret.encode(), f'{parts["t"]}.'.encode() + raw_body, hashlib.sha256).digest()
    ).decode()
    return hmac.compare_digest(expected, parts["v1"])

Two things decide whether this works:

Sign over the raw bytes you received. Re-serialising the JSON first changes whitespace or key order, and the signature will not match.

Ordering, duplicates, and gaps

When a delivery does not arrive

Every attempt is recorded and readable:

curl -sS -H "Authorization: Bearer $TOKEN" \
  https://<host>/v1/webhooks/$ENDPOINT/deliveries
{"data": [{
  "id": "0193…", "event": "scheduling.meeting_ended", "sequence": 41,
  "status": "pending", "attempt": 2, "next_retry_at": "2026-09-05T15:02:00Z",
  "attempts": [
    {"attempt": 1, "outcome": "http_error", "response_status": 502, "duration_ms": 118, "attempted_at": "…"},
    {"attempt": 2, "outcome": "timeout", "error": "No response within 10s.", "attempted_at": "…"}
  ]
}]}

outcome distinguishes four different failures: http_error (you answered, badly), timeout (you did not answer), connection_failed (you could not be reached), target_not_permitted (your hostname now resolves to a private address). Your own response body is never stored.

Backoff is 30s, 2m, 10m, 1h, 4h, up to 6 attempts inside 24 hours. An endpoint that fails 20 deliveries in a row is auto-disabled and your tenant administrators are told; PATCH /v1/webhooks/{id} {"status":"active"} re-enables it and clears the counter.

Replay one by hand with POST /v1/webhooks/{id}/deliveries/{delivery}/redeliver. It re-sends the recorded transition — the same payload, the same occurred_at, a new delivery id and a new sequence — and never re-runs the operation that produced it, which is what makes replaying safe. A delivery that has not finished yet is 409 delivery_not_replayable.


6. Read what the meeting produced

GET /v1/meetings/{id}/artefacts     recordings, transcripts, summaries   artefacts:read
GET /v1/meetings/{id}/consents      the consent decisions of record      consents:read
GET /v1/audit-events?action=…       everything that happened             audit:read

action takes an exact name or a dotted prefix. Your own tenant's events, and nobody else's.

Listing routes page with ?limit= and ?cursor=. limit is between 1 and 100 on this deployment; outside that it is refused, with limit_invalid beside the pointer. The response carries pagination.next_cursor, and a null cursor means there is no next page.

An event your application caused carries no user as its actor, because no person did it, and names your application in its payload instead. That is what an auditor will see.


7. What happens when things go wrong

What happened What you get What is left behind
The room cannot be reached when you create a meeting nothing — creating a meeting does not depend on it the meeting
The room cannot be reached when you mint a join link 503 room_not_provisioned no token; nobody gets a link into a room that was not prepared
An invitee's address is malformed 422 naming /participants/{n}/subject/email nothing at all
Two invitees in one request carry the same external_ref 422 naming /participants/{n}/external_ref nothing at all
You invite somebody with an external_ref another person on the meeting holds 409 external_ref_taken nothing; the meeting and its roster are untouched
You send both organiser and organiser_user_id 422 nothing at all
An invitee's address is valid and undeliverable 201 — nobody can tell in advance the meeting stands; the send is retried and then abandoned. No event reports it, and no call reads it back
You retry a create that timed out the original 201, Idempotency-Replayed: true one meeting
You reuse a key for a different meeting 409 idempotency_key_reused nothing
Two of your workers race with one key one 201, one 409 idempotency_request_in_flight one meeting
Someone holds a join link and you cancel no new tokens (409 not_joinable_cancelled) the live token works until it expires, ten minutes
You mint a link for a meeting whose window has not opened 409 not_joinable_yet, naming the instant it opens nothing
Your webhook endpoint is down 2xx to the caller who caused the event a pending delivery with next_retry_at, retried on the backoff
Your webhook endpoint returns 500 as above an attempt row with outcome: http_error and your status
Your webhook endpoint is slow as above an attempt row with outcome: timeout
Your clock is wrong 401 token_expired or token_not_yet_valid, naming both clocks and the tolerance nothing
Your token lacks a scope 403 scope_not_granted, naming the scope nothing
You use another tenant's id 404, never 403 nothing — and the answer discloses nothing about what exists there

Every refusal has its own stable code. Branch on it rather than parsing a sentence.

A member this API does not define is refused, not ignored. Send {"passcode": "1234"} on POST /v1/meetings and you get 422 naming /passcode, because this platform has no passcode and a 201 with your setting quietly dropped would be worse than an error. The same applies to a member sent in the wrong place: role inside subject is refused at /participants/0/subject/role rather than silently doing nothing.


Boundaries of this API

Stated so you do not build against something that is not there.