Integrating with inTouch

Everything needed to integrate with the deployment at https://app-dev.intouchcourses.com, in one file. Generated 2026-10-17T03:46:14Z from the same source, and on request, so the pages at https://app-dev.intouchcourses.com/docs and this file are the same document. Nothing is fetched to render it; the only outward links are two to the RFC editor.

Start here

inTouch runs video meetings, and your application drives them over a plain web API. Your application signs a short-lived JSON Web Token with a private key you generate, and sends it in an Authorization: Bearer header on ordinary HTTPS requests carrying JSON. Any language and any framework can do that: there is no library, no software development kit, and nothing to install.

The worked examples on the other pages are written in one language because a document has to choose one. Nothing on this platform requires that language, or any other.

Addresses on every page are written as https://<host>. Replace the whole prefix — scheme, host and port — with the deployment address you were given, which is the address these pages are served from. Here is one request, four ways — each row is the call itself, with your language's usual imports assumed. It is the call in One call you can make right now below:

Language The call
Shell curl -s https://<host>/v1/integrator/discovery
Python json.load(urllib.request.urlopen("https://<host>/v1/integrator/discovery"))
PHP json_decode(file_get_contents("https://<host>/v1/integrator/discovery"), true)
Java HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create("https://<host>/v1/integrator/discovery")).build(), BodyHandlers.ofString()).body()

What your administrator gives you

A workspace administrator — someone at your own company — registers your application in the developer console at https://<host>/app/console, registers the public key you send them, and gives you four values, the last of them only if you want webhooks. That is everything anyone else does for you.

Value What you do with it
Application identifier The iss claim of every token you sign.
Tenant identifier The tenant_id claim of every token you sign.
Key identifier, kid The kid member of your token header, naming which of your public keys signed it.
Webhook signing secret Verifies the deliveries this platform sends you. Only if you want to be told about events rather than asking for them.

You generate the key pair yourself, keep the private half, and send the public half to your administrator. What to ask for, and how, is in Getting your first meeting running.

One call you can make right now

This one needs no credential and no registration. Run it against the address you were given:

curl -s https://<host>/v1/integrator/discovery

It answers with what this deployment expects of a token. Read these values from it rather than writing them into your application. Abridged below — claims, scopes, kyc_scopes and webhooks are left out here, and the response you just fetched carries all four:

{
  "audience": "intouch-integrator-api",
  "algorithm": "RS256",
  "required_claims": ["iss", "aud", "tenant_id", "scopes", "exp"],
  "clock_skew_seconds": 60,
  "max_token_lifetime_seconds": 3600,
  "max_active_keys_per_application": 5,
  "rate_limit_per_minute": 120
}

What an integration does, end to end

Six steps, in order. Each is one section of the guide.

  1. Sign a token with your private key, and check it works before anything depends on it.
  2. Create a meeting and invite people to it.
  3. Get one person in by minting their join link.
  4. Put the room in your page, or send them the meeting's own entry link.
  5. Be told what happened by subscribing a webhook endpoint.
  6. Read back what the meeting produced — its recordings, transcripts and summaries.

Steps 1 to 3 are enough to have somebody in a room. Steps 4 to 6 are each independent of the others.

Where to go next

You want Read
To build the six steps above, in order Getting your first meeting running
The token, its claims, and rotating a key Authenticating your application
The meeting lifecycle in full Running a meeting from your own application
What a refusal means and what to change Error reference

Start with the first row.

Getting your first meeting running

This page is the whole integration in order. It has three parts, and they map onto the three places work happens.

Part Where What happens
1 Your own machine You generate a key pair. The private half never leaves it.
2 The console, in a browser Your workspace administrator registers your application and everything it needs. Four values come back to you.
3 Your application You sign a token, create a meeting, get people in, and read back what the meeting produced.

You need no account to read this and no shell access to this platform at any point. Read the parts in order the first time: part 3 uses what part 2 gives you.

Addresses below are written as https://<host>. Replace the whole prefix with the deployment address you were given, scheme and port included — a development deployment is often http:// on a port, and substituting only the host name leaves you calling an address that does not answer.


Part 1. On your own machine: generate a key pair

Your application proves who it is by signing a short-lived token with a private key. You generate that key, and it stays with you: no endpoint accepts a private key, no response returns one, and a private key sent to the key-registration surface is refused before it is stored.

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem
openssl rsa -in private.pem -pubout -out public.pem
chmod 600 private.pem

RSA, 2048 bits or more. RS256 is the only algorithm this deployment verifies, and it is fixed in the verifier rather than read from the token header.

If you already hold a key in another format, convert it rather than generating a new one:

What you have Conversion
OpenSSH (ssh-rsa AAAA…) ssh-keygen -e -m PKCS8 -f id_rsa.pub > public.pem
PKCS#1 (BEGIN RSA PUBLIC KEY) openssl rsa -RSAPublicKey_in -in pkcs1.pem -pubout -out public.pem

What to send on. public.pem, by any channel. It is not a secret. private.pem stays where you made it.


Part 2. In the console: what to ask your administrator for

A workspace administrator opens https://<host>/app, signs in — the page sends a one-time code to their address and takes it back — and opens Developer console.

Four things are registered there. Two of them are only needed for part of an integration, so ask for the ones yours uses, in one message.

Ask them to register your application

They enter a name. The application's page then shows the deployment address, this documentation's address, and the two identifiers you need.

What comes back to you:

Value What you do with it
The application identifier The iss claim of every token you sign.
The tenant identifier The tenant_id claim of every token you sign, and the X-Tenant-Id header where you send one.

Both are required in every token, and neither can be read back without a token that already carries them. Take both now.

Ask them to add your public key

On the application's page, Add a public key: they paste the contents of public.pem and add it. The screen answers with two values.

What comes back to you:

Value What you do with it
kid, the key identifier Goes in your token header. Keep it: the only place it comes back is authenticated_with.kid on GET /v1/applications/me, which needs a token that already carries it.
fingerprint_sha256 Check it against your own file.

Check that the deployment holds the key you meant:

openssl pkey -pubin -in public.pem -outform DER | openssl dgst -sha256 -r | cut -d' ' -f1

That prints the same value the console shows.

Ask them to register the origins your pages are served from

Only if you are embedding the meeting room in your own pages. On the same page, Where the room may be framed: the origins your pages are served from, one per line.

An origin is scheme, host and optional port, and nothing else — that is what a browser compares. http://localhost:8902 and http://127.0.0.1:8902 are different origins, so register the one you actually open.

Entry Result
https://app.example.com Accepted
https://app.example.com:8443 Accepted
http://localhost:8902, http://127.0.0.1:8902 Accepted — a browser treats loopback as a secure context
https://app.example.com/ Refused: an origin has no path
https://app.example.com/embed Refused: same reason
https://*.example.com Refused: the browser policy matches origins, not host patterns
http://app.example.com Refused: plaintext, off loopback

At most ten origins per application. The list is replaced rather than added to, so removing an origin withdraws it.

An origin becomes usable once it has been registered and the deployment has applied the change. Until then a page served from it shows an empty frame; see part 3.6.

Ask them to register a webhook endpoint

Only if you want to be told when something happens rather than polling for it. On Developer console → Webhook endpoints: your endpoint address and the events you want. The screen lists every event this deployment publishes with a line saying what each one means.

What comes back to you: the signing secret, shown once. If it is lost, the endpoint's own page issues a new one; there is no way to read the old one back.

That page also lists every delivery attempt against the endpoint: which event, when, what your server answered, how long it took, and the error where there was one. It is the first place to look when a delivery has not arrived.

What you should hold at the end of part 2

  1. the deployment address, which you already had;
  2. the application identifier;
  3. the tenant identifier;
  4. the kid of your key;
  5. the webhook signing secret — only if you registered an endpoint.

You have held the private key the whole time.

That is the whole list, and it is about your application, not about people. There is no user identifier to collect, because you create no accounts in this platform — not for the organiser of a meeting, not for the people you invite. You name them in part 3 the way you already know them, a name and an email address, each carrying your own identifier for them. Nobody signs up and nobody is provisioned. If your product has fifty thousand users, this platform holds none of them.

None of it can be recovered from the API without a working credential, and the signing secret cannot be read back at all. Store them where your application reads its configuration.


Part 3. In your application: build the integration

3.1 Read what this deployment expects

curl -s https://<host>/v1/integrator/discovery

No credential is needed. It answers with the audience to sign for, the algorithm, the claim names, the twelve scopes, which two of them are KYC scopes, the clock tolerance, the maximum token lifetime, the rate limit, and the webhook event catalogue. Abridged below — claims, scopes, kyc_scopes and webhooks are left out:

{
  "audience": "intouch-integrator-api",
  "algorithm": "RS256",
  "required_claims": ["iss","aud","tenant_id","scopes","exp"],
  "clock_skew_seconds": 60,
  "max_token_lifetime_seconds": 3600,
  "max_active_keys_per_application": 5,
  "rate_limit_per_minute": 120
}

Read these values rather than hard-coding them. A deployment is allowed to differ, and this is where it says so.

Two values a developer wants are not in discovery, because they are properties of the meeting rather than of the credential: how long a join link lasts, and how early a meeting becomes joinable. Both are ten minutes on this deployment, and both are stated in 3.5. The refusal you get for asking early carries the exact instants, so you never have to compute the boundary.

3.2 Sign a token

Header

{ "alg": "RS256", "typ": "JWT", "kid": "k_9f3c1d2e4a5b6c7d8e9f0a1b2c3d4e5f" }

Claims

{
  "iss": "<application id>",
  "aud": "<the audience from discovery>",
  "tenant_id": "<tenant id>",
  "scopes": ["meetings:read"],
  "exp": 1787700000,
  "iat": 1787699700,
  "jti": "<a unique string>"
}

The header takes alg, typ and kid, and nothing else. alg and kid are required: a header with no kid is token_malformed, and one with no alg is token_algorithm_unsupported. typ is optional, and where you send it the only accepted value is "JWT".

iss, aud, tenant_id, scopes and exp are required claims. sub is optional and must equal iss if you send it. nbf, iat and jti are optional.

Both sets are closed, and so is the scope set. Anything outside them is refused by name, with a JSON Pointer to it, rather than accepted and ignored:

What you did What you get Pointer
A header or claim this API does not define token_claim_unknown /header/… or /claims/…
A required claim absent token_claim_malformed, errors[].code: required the claim
A claim of the wrong type token_claim_malformed, errors[].code: type the claim
A scope outside the twelve token_claim_malformed, errors[].code: unknown /claims/scopes/{n}
An empty scopes array token_claim_malformed /claims/scopes

scopes must name at least one scope, even on routes that enforce none.

The two KYC scopes never share a token with the other ten. Discovery names them in kyc_scopes. A token carrying one of each class is refused with token_scope_classes_mixed; sign two tokens.

Signing needs no library. This is a complete program: put it in a file, set the four values, run it, and it prints a token this deployment accepts.

import { createSign } from 'node:crypto';
import { readFileSync } from 'node:fs';

const APP_ID  = process.env.INTOUCH_APPLICATION_ID;   // the token's `iss`
const TENANT  = process.env.INTOUCH_TENANT_ID;
const KID     = process.env.INTOUCH_KEY_ID;
const KEY     = readFileSync(process.env.INTOUCH_PRIVATE_KEY, 'utf8');
const AUD     = 'intouch-integrator-api';         // read `audience` from discovery instead

const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', typ: 'JWT', kid: KID };
const claims = {
  iss: APP_ID, aud: AUD, tenant_id: TENANT,
  scopes: ['meetings:read'],
  iat: now, exp: now + 300, jti: `${APP_ID}-${now}`,
};

const b64 = (o) => Buffer.from(typeof o === 'string' ? o : JSON.stringify(o)).toString('base64url');
const input = `${b64(header)}.${b64(claims)}`;
const jwt = `${input}.${createSign('RSA-SHA256').update(input).sign(KEY).toString('base64url')}`;
console.log(jwt);

AUD is written out so the program runs on its own. In anything you keep, read audience from discovery.

Change scopes as you go. The program above asks for meetings:read, which is enough for 3.3. Creating a meeting needs meetings:write, minting a join link tokens:mint, managing webhook endpoints webhooks:manage, and reading artefacts artefacts:read. A token missing the scope an operation needs is refused with 403 scope_not_granted, and the refusal names the scope that was wanted.

Segments are base64url without padding, and this is enforced (RFC 7515 §2). A standard base64 encoder produces +, / and =, and each of those is refused as token_malformed, with a detail naming the fault and the encoder call that fixes it.

Language The call
Node Buffer.from(x).toString('base64url')
Python base64.urlsafe_b64encode(x).rstrip(b'=')
PHP rtrim(strtr(base64_encode($x), '+/', '-_'), '=')
Go base64.RawURLEncoding.EncodeToString(x)

The refusal itself names the first three; Go is here because the call exists, not because the message will offer it.

3.3 Check the credential

Do this before anything else depends on it.

curl -s https://<host>/v1/applications/me -H "Authorization: Bearer <jwt>"
{
  "application": {
    "id": "…", "tenant_id": "…", "name": "orders-service",
    "status": "active", "framing_origins": ["https://app.example.com"],
    "created_at": "2026-08-28T22:04:11Z"
  },
  "authenticated_with": { "kid": "k_9f3c…", "fingerprint_sha256": "8f2b…" },
  "scopes": ["meetings:read"]
}

scopes is what your token actually carries. framing_origins is the embedding allowlist read back; it is [] until origins are registered.

This route enforces no particular scope, because a credential identifying itself is not a grant over a resource. Your token must still name one.

If this call is refused, stop here: nothing further can work. The code says what went wrong, and every code is in the Error reference.

Fix one fault at a time. The credential is checked in a fixed order: the three-segment shape; then the header and claims — which members they carry, and the values of alg, typ, sub, tenant_id and scopes; then the key named by kid; then the signature; and last the values of aud, exp and nbf. The first failure is the one you are told about, so until a registered kid resolves, a wrong audience, an expired token and a bad signature all read as token_key_unknown, and a token can carry more faults than one refusal shows.

3.4 Create a meeting

curl -s -X POST https://<host>/v1/meetings \
  -H "Authorization: Bearer <jwt with meetings:write>" \
  -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,
    "organiser": {"type": "guest", "name": "Rosa Kaur", "email": "rosa@yourcompany.example"},
    "participants": [
      {"subject": {"type": "guest", "name": "Ada Byron", "email": "ada@example.com"},
       "external_ref": "your-user-4471"}
    ]
  }'

The 201 carries the occurrence and every participant created with it, in one transaction: they commit together or none of them does.

Idempotency-Key must be a UUID — a fresh one per intention, the same one on a retry. A retry that finds the stored response answers with it unchanged and adds Idempotency-Replayed: true. It is required on the four requests your application makes that create something:

POST /v1/meetings
POST /v1/meetings/{id}/participants
POST /v1/meetings/{id}/join-tokens
POST /v1/webhooks

No other call needs it, and POST /v1/artefacts/{id}/download-reference in 3.8 deliberately does not take one.

vertical is one of meeting, conference, webinar and learning. It fixes the meeting's capacity — 20, 500, 500 and 100 respectively — and which features the room offers. capacity comes back in the response and is not a member you can set. The optional members, and the recurrence rules, are in Running a meeting from your own application; anything this API does not define is refused by name rather than ignored.

entry_url in the response is the link you send people. It carries no credential, is stable for the meeting's whole life, and is safe in an email, a calendar entry, or your own interface. Whoever opens it identifies themselves there and is issued their own token.

Send the entry_url you were given rather than building one. That address answers 200 for any well-formed meeting identifier, including one that names no meeting, and shows a neutral page that admits nobody — the page faces the open internet and does not report which meetings exist. So a mistyped identifier looks like a working link, and fails only when somebody tries to enter.

Reading participants back is a separate call. GET /v1/meetings/{id} does not carry participants; GET /v1/meetings/{id}/participants does. The create response includes them because that request created them and you have no other way to learn their identifiers.

organiser is required of an application and only of an application. Your application is not a person, so it has to say who owns the meeting. The organiser is the meeting's owner, the ICS ORGANIZER, and the address the calendar copy goes to. A signed-in user calling the same endpoint becomes the organiser themselves and may omit it.

They do not need an account here. {"type": "guest", "name": …, "email": …} names one of your users, and they are the organiser in full. No account is created on this platform and no administrator is involved. That is the same subject shape you use for participants below. Where the organiser really is a member of your tenant, {"type": "user", "user_id": …} names them and is refused with 422 organiser_not_member if they are not an active one.

Omitting the organiser altogether is 422 organiser_required.

external_ref is your own identifier for a person — on the organiser as organiser_external_ref, and on any participant. We store it, hand it back on every read, and put it in the audit trail and in the events, without ever parsing it. It saves you keeping a table mapping our identifiers to yours. At most 200 characters, and no two people on one meeting may share one.

organiser_user_id still works and is exactly organiser: {"type": "user", "user_id": …}. Send one or the other, never both.

Recurrence, the ceiling of 100 invitees per call, editing and cancelling are in Running a meeting from your own application.

curl -s -X POST https://<host>/v1/meetings/<id>/join-tokens \
  -H "Authorization: Bearer <jwt with tokens:mint>" \
  -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"}

A subject is one of two shapes, here and everywhere else one is accepted:

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

Seven rules govern this call.

  • The person must already be on the meeting. A join token names somebody with standing on that occurrence: a participant, or its organiser. Minting for anyone else is 403 subject_not_on_occurrence. Invite them first with POST /v1/meetings/{id}/participants, which takes the same subject shape and needs meetings:write.
  • The first mint takes the occurrence live, and that closes the roster. From that moment POST /v1/meetings/{id}/participants answers 409 not_editable. Invite everybody before you mint for anybody; afterwards, entry_url is the way in for somebody who is not on the list.
  • There is a join window. A scheduled occurrence becomes joinable ten minutes before it starts on this deployment, and stops being joinable at its end. Earlier than that is 409 not_joinable_yet, carrying window_opens_at and window_closes_at so the boundary never has to be derived.
  • The link is short-lived. Ten minutes on this deployment. Mint it when the person is about to join, not when you send the invitation.
  • One link, one person, one meeting. The token names that occurrence and nobody else's standing on it. Forwarding it hands somebody else that person's way in; for a link to put in an invitation, use entry_url.
  • The room is prepared before the token is signed. Where it cannot be, the answer is 503 room_not_provisioned and no token is issued. The cause member says which of three: unconfigured, unreachable, refused.
  • A role may be asked for, and is decided here. The body takes an optional role of moderator, participant or visitor; omit it and the person's standing on the occurrence decides. A role above your caller's own authority is 403 role_not_permitted rather than a token that quietly grants less than you asked for, and a visitor on a vertical that admits none is 403 visitor_tier_not_offered. Promote a visitor afterwards with POST /v1/meetings/{id}/participants/{participantId}/promote, which needs tokens:mint.

This is also the only call in this page that depends on the room being reachable. Creating, reading, editing, cancelling and ending a meeting do not.

3.6 Put the room in your page

<room-host> is the host of the join_url returned by 3.5 — new URL(join_url).host. Take it from there rather than configuring it separately: discovery does not publish it.

The room ships its own JavaScript API at /external_api.js on that host. Load that script and construct JitsiMeetExternalAPI with the meeting id as the room name and the token from 3.5:

<script src="https://<room-host>/external_api.js"></script>
<div id="room" style="height: 70vh"></div>
<script>
  new JitsiMeetExternalAPI('<room-host>', {
    roomName: '<the meeting id>',
    jwt: '<the token from 3.5>',
    parentNode: document.getElementById('room'),
    width: '100%', height: '100%',
  });
</script>

The room refuses to be framed by an origin that has not been registered for your application. It answers with Content-Security-Policy: frame-ancestors naming the origins that may frame it, and a browser asked to frame it from anywhere else blocks the load before any script runs. What you see is an empty box, and the reason appears only in the browser console:

Framing 'https://<room-host>/' violates the following Content Security
Policy directive: "frame-ancestors 'self' https://app.example.com".
The request has been blocked.

Registering the origins is your administrator's act in the console, described in part 2. Your application credential cannot widen its own framing allowlist: the list is a security boundary, and a credential that could widen the boundary it sits inside would not be one. A change is audited with both the old and the new list.

X-Frame-Options is not sent with the room. Its grammar is DENY or SAMEORIGIN and nothing else, so it cannot express an allowlist; the frame-ancestors policy above is the one a browser enforces.

Two more things to get right:

  • Give the container a real height. A frame in a zero-height parent renders nothing and does not warn you.
  • Load external_api.js from <room-host>, not from a content delivery network, so the script and the room it drives are the same version.

Your application credential never reaches the browser. The page asks your own server for a join token, and your server holds the private key. That separation is the security model of an embedded room.

This API sends no cross-origin headers and is not callable from a browser: a page fetching /v1/… directly will be blocked by the browser's own same-origin rule. Every call in part 3 belongs on your server.

Routes that need a person, not an application. These accept a browser session only. A bearer token is refused on all of them with 401 no_valid_principal, whatever scopes it carries. This is the complete set:

GET    /v1/auth/session
DELETE /v1/auth/session
POST   /v1/auth/step-up
GET    /v1/me
PATCH  /v1/me
GET    /v1/tenant
GET    /v1/users
POST   /v1/users
GET    /v1/users/{userId}
PATCH  /v1/users/{userId}
DELETE /v1/users/{userId}
GET    /v1/directory
GET    /v1/applications
POST   /v1/applications
GET    /v1/applications/{applicationId}
PATCH  /v1/applications/{applicationId}
GET    /v1/applications/{applicationId}/keys
POST   /v1/applications/{applicationId}/keys
DELETE /v1/applications/{applicationId}/keys/{keyId}
GET    /v1/meetings/{meetingId}/knocks
POST   /v1/knocks/{knockId}/admit
POST   /v1/knocks/{knockId}/refuse

The last three are the admission queue for a meeting with a lobby: a moderator answers a knock in a browser. An application credential holds no standing on an occurrence, so it cannot admit anybody.

3.7 Receive events

Your administrator registers the endpoint and gives you the signing secret (part 2). You can also register one with your own credential:

curl -s -X POST https://<host>/v1/webhooks \
  -H "Authorization: Bearer <jwt with webhooks:manage>" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/svc","events":["scheduling.meeting_ended"]}'

The 201 carries the signing secret once. Store it now; no endpoint returns it again.

The address must be https://, on port 443 or 8443, and must resolve publicly at the time you register it. No other port is accepted. Names reserved by RFC 2606 and RFC 6761 — anything under .example, .invalid, .test or .localhost — resolve nowhere on the public internet and are refused with 422 target_not_permitted, as are addresses that resolve to a loopback, private, link-local or cloud-metadata address. The check runs again at delivery time, after DNS. (This is about where a delivery can be sent. It is unrelated to the framing origins in part 2, where a loopback address is accepted because the browser is your own.)

Verifying a delivery. X-Svc-Signature carries t=<unix seconds> and v1=<base64 HMAC-SHA256 of "<t>.<raw body>">, keyed with your endpoint secret. Two things decide whether your verification works:

  • The whole secret is the key, whsec_ prefix included. It is part of the key, not a label.
  • Sign over the raw bytes you received. Re-serialising the JSON changes whitespace or key order, and the signature will not match.

The full recipe, with a working implementation, is in Running a meeting from your own application.

An events entry is an exact name or a family prefix. A prefix is a name truncated at a dot, with the dot kept and nothing after it: scheduling. subscribes every event whose name begins with it, and artefact. does the same. There is no wildcard character, and scheduling without the dot is not a prefix. GET /v1/integrator/discovery lists the exact names; the prefixes are formed from them by this rule. An entry that matches no event is refused at registration with 422 event_unknown and its index.

One action can produce more than one delivery. Creating the meeting in 3.4 sends scheduling.meeting_created and one scheduling.participant_invited per invitee.

Arrival order is not causal order. De-duplicate on id, order on occurred_at, and use sequence only to notice a delivery you never received. A meeting_ended can reach you before the meeting_started before it. A consumer that trusts arrival order corrupts its own state.

scheduling.meeting_started fires when the first join link is minted for the occurrence, not when a person enters the room. It means that somebody has asked for a way in.

3.8 Read back what the meeting produced

There are two ways to learn that an artefact exists.

Poll. This needs no public address and works in your first hour.

curl -s https://<host>/v1/meetings/<id>/artefacts \
  -H "Authorization: Bearer <jwt with artefacts:read>"

Ask after the meeting has ended. Nothing to register, nothing to expose, no inbound address, and it stays the right answer for anything batch-shaped.

Be told. Subscribe an endpoint to artefact. and receive one event per artefact.

curl -s -X POST https://<host>/v1/webhooks \
  -H "Authorization: Bearer <jwt with webhooks:manage>" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/svc","events":["artefact."]}'

artefact.registered fires once per artefact, for every type this deployment produces: recording, transcript, summary, action_points, shared_file, poll_results. A meeting that produces four artefacts sends four events, each as its own artefact becomes ready.

{
  "id": "01a069…", "event": "artefact.registered", "sequence": 41,
  "occurred_at": "2026-09-05T14:52:22.481Z", "tenant_id": "01a06…",
  "data": {
    "meeting_id": "01a069a6-31ce-7200-918a-a3ab84d89e91",
    "artefact_id": "01a069b1-4c2a-7f18-9e10-5b2d0c6a77e3",
    "type": "transcript",
    "size_bytes": 4213,
    "content_hash": "9f2c…",
    "produced_at": "2026-09-05T14:52:19.000Z",
    "language": "en",
    "list_url": "https://<host>/v1/meetings/01a069a6-…/artefacts",
    "download_reference_url": "https://<host>/v1/artefacts/01a069b1-…/download-reference"
  }
}

The event carries no artefact and no credential. It states a fact and gives the two addresses that act on it; the bytes take an authenticated call with your own token. content_hash is how you confirm the bytes you download are the ones the event was about.

language is present only where the producer stated one. It is omitted rather than guessed at.

Order on occurred_at rather than on arrival. The artefacts of one meeting are produced seconds apart.

Fetching the bytes is the same two calls either way.

curl -s -X POST https://<host>/v1/artefacts/<artefact id>/download-reference \
  -H "Authorization: Bearer <jwt with artefacts:read>"

This one takes no Idempotency-Key and is not idempotent by design: each call mints a new single-use reference, and that is the point.

The 201 carries a url. It is absolute, on this deployment, and it is the only address that redeems the reference — do not construct one. Fetch it with no credential:

curl -s "<the url from the response>"

The reference is the authorisation. It carries no credential in its address, is single-use — a second fetch is 404 download_reference_invalid — expires in minutes, and can be revoked. Fetch it straight away rather than storing it.


Rotating a key

Rotation is register, deploy, retire. An application holds several live keys at once, so there is no moment in the middle where nothing can sign.

  1. Generate the next pair on your own machine, exactly as in part 1, and send the new public.pem to your administrator.
  2. Ask them to add it on the application's page. Both keys verify from that moment.
  3. Deploy the new private key in your application.
  4. Ask them to revoke the old key, on the same page.

Before step 4, the console row for the old key shows when it last signed an accepted token. Read it rather than discovering the answer from an outage.

Revocation is a console action rather than one you can script from your side. The route behind it takes the key's own identifier, which is not the kid your token header carries and is not shown to you; the console holds both.

Revocation takes effect immediately: a token signed with a revoked key is refused from that moment whatever its exp says, with token_key_revoked — a different code from token_key_unknown, so "switched off" and "never heard of it" can be told apart.

Revoking the last active key is permitted, so that a leaked key can be withdrawn without waiting for a replacement to be arranged.

Your application cannot rotate its own keys. No scope grants credential self-management, and the operation stays with a workspace administrator.


When a request is refused

Every code is in the Error reference, with what causes it and what to change.

Every response carries X-Correlation-Id, and a refusal repeats it in the body as correlation_id. Send one of your own and it is echoed back, so a trace joins at both ends; send none and this deployment generates one. Log it, and quote it when you report a fault.

Three things are true of every refusal:

  1. Branch on code, never on detail. The code is the contract.
  2. No two codes share a cause, so a code always narrows the problem. Where one code covers more than one fault, the error reference names them all and the detail says which one you met.
  3. A refusal leaves nothing behind. Every mutation is one transaction, and a failed request spends no idempotency key: fix the body and send it again with the same key.

Where to go next

You want Read
The whole meeting lifecycle in detail Running a meeting from your own application
Authentication in depth, and rotation Authenticating your application
Every refusal code Error reference

Two surfaces audit:read reaches that this page does not otherwise use: GET /v1/audit-events, your tenant's audit log, filtered with ?action= and paged with ?limit= and ?cursor=; and GET /v1/isolation-attestation, which reports what this deployment enforces to keep one tenant's data away from another's.

Authenticating your application

This is how your application proves who it is to this platform. There is no user, no login and no session involved. You generate a key pair on your own machine, your administrator registers the public half, and you sign a short-lived token with the private half.

This platform never generates, transmits, holds or downloads your private key. No endpoint has a field for one and no response returns one. A private key pasted into the key-registration form is refused before it is stored, as key_material_is_private.


1. Find out what this deployment expects

curl -s https://<host>/v1/integrator/discovery
{
  "audience": "intouch-integrator-api",
  "algorithm": "RS256",
  "claims": ["iss","sub","aud","tenant_id","scopes","exp","nbf","iat","jti"],
  "required_claims": ["iss","aud","tenant_id","scopes","exp"],
  "scopes": {
    "tokens:mint": "Mint a join token for an occurrence, and promote a visitor.",
    "meetings:read": "Read scheduling resources.",
    "meetings:write": "Create a meeting or a recurring series.",
    "artefacts:read": "Browse the artefact library.",
    "recordings:control": "Start and stop recording.",
    "consents:read": "Read consent receipts and export the evidence report.",
    "entitlements:read": "Read the tenant's effective entitlements.",
    "usage:read": "Read metering and usage.",
    "audit:read": "Query and export the tenant audit log; read the isolation attestation.",
    "webhooks:manage": "Manage webhook endpoints.",
    "kyc:manage": "The KYC operating surface.",
    "kyc:evidence": "The KYC evidence surface."
  },
  "kyc_scopes": ["kyc:manage", "kyc:evidence"],
  "clock_skew_seconds": 60,
  "max_token_lifetime_seconds": 3600,
  "max_active_keys_per_application": 5,
  "rate_limit_per_minute": 120
}

No credential is needed for this call. Read these values rather than hard-coding them: a deployment is allowed to differ, and this is where it says so.

2. Generate a key pair on your own machine

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem
openssl rsa -in private.pem -pubout -out public.pem
chmod 600 private.pem

private.pem stays with you. It never leaves your infrastructure, and nothing in this document asks you to send it.

RSA, 2048 bits or more. RS256 is the only algorithm this deployment verifies, and it is fixed in the verifier rather than read from your token's header, so there is no algorithm for an attacker to negotiate downwards.

If you already hold a key in another format, the refusal names the format you sent and gives the conversion:

What you have Conversion
OpenSSH (ssh-rsa AAAA…) ssh-keygen -e -m PKCS8 -f id_rsa.pub > public.pem
PKCS#1 (BEGIN RSA PUBLIC KEY) openssl rsa -RSAPublicKey_in -in pkcs1.pem -pubout -out public.pem

3. Ask your administrator to register the application and the key

A workspace administrator signs in at https://<host>/app, opens Developer console, and registers an application by name. The screen answers with the application identifier, which is the iss claim of every token you sign.

On that application's page, Add a public key: they paste the contents of public.pem. The screen answers with two values to send back to you.

Value What you do with it
kid, the key identifier Goes in your token header. It is not derivable from the key material, and the only place it comes back is authenticated_with.kid on GET /v1/applications/me — which needs a token that already carries it.
fingerprint_sha256 Check it against your own file.
openssl pkey -pubin -in public.pem -outform DER | openssl dgst -sha256 -r | cut -d' ' -f1

That prints the same value as fingerprint_sha256.

Registering an application and registering a key are separate acts on separate resources, because rotation is register, deploy, retire: an application holds several live keys at once, so there is no moment in the middle where you hold no working credential. See §5.

Both are administrator operations, in a browser session. A credential cannot register a credential.

4. Sign a token

Header

{ "alg": "RS256", "typ": "JWT", "kid": "k_9f3c1d2e4a5b6c7d8e9f0a1b2c3d4e5f" }

Claims

{
  "iss": "<application id>",
  "aud": "<the audience from discovery>",
  "tenant_id": "<tenant id>",
  "scopes": ["audit:read"],
  "exp": 1787700000,
  "iat": 1787699700,
  "jti": "<a unique string>"
}

aud is shown as a placeholder. On this deployment discovery answers intouch-integrator-api; read the value from discovery rather than copying it from here, because a deployment is allowed to differ.

Claim Required Notes
iss yes your application's UUID
aud yes exactly the audience from discovery
tenant_id yes the tenant this application belongs to
scopes yes a non-empty array from the twelve in discovery
exp yes integer seconds since the epoch
sub no if you send it, it must equal iss
nbf no integer seconds
iat no integer seconds
jti no non-empty string

The header takes alg, typ and kid, and nothing else. alg and kid are required; a token whose header carries no kid is refused as token_malformed. typ is optional, and where you send it the only accepted value is "JWT". A header member this API does not define is refused as token_claim_unknown at /header/….

The claim set is closed. Any other member is refused, by name, with a JSON Pointer to it. A misspelled claim, or one nested inside a wrapper object, is an error rather than a token that works and silently does less than you intended.

A required claim that is absent is token_claim_malformed with errors[].code: required, not a separate code. Read errors[].code alongside the pointer: required for absent, type for the wrong JSON type, format for the wrong shape, unknown for a scope this API does not define.

The scope set is closed too. There are exactly twelve, listed in discovery. A value outside them is refused with its index rather than ignored.

KYC scopes never share a credential with the rest. kyc:manage and kyc:evidence are one class; the other ten are another. A token naming both classes is refused with token_scope_classes_mixed. Sign two tokens.

Send it as an ordinary bearer credential:

curl -s https://<host>/v1/applications/me \
  -H "Authorization: Bearer <the signed JWT>"
{
  "application": { "id": "…", "tenant_id": "…", "name": "orders-service", "status": "active", "framing_origins": [], "created_at": "…" },
  "authenticated_with": { "kid": "k_9f3c…", "fingerprint_sha256": "8f2b…" },
  "scopes": ["audit:read"]
}

scopes in that response is what your token actually carries. Check it before anything else depends on the credential.

Fix one fault at a time. The credential is checked in a fixed order: the three-segment shape; then the header and claims — which members they carry, and the values of alg, typ, sub, tenant_id and scopes; then the key named by kid; then the signature; and last the values of aud, exp and nbf. The first failure is the one reported, so until a registered kid resolves, a wrong audience, an expired token and a bad signature all read as token_key_unknown — but a bad sub or a malformed tenant_id does not, and is reported straight away.

This route enforces no particular scope: a credential identifying itself is not a grant over a resource, and you should be able to test your key before you have decided anything else. It does still need a token naming at least one scope. An empty scopes array is refused with token_claim_malformed at /claims/scopes.

You may send X-Tenant-Id. If you do, it must match the token's tenant_id; a request whose header and credential disagree is refused rather than resolved in either direction.

What token lifetime buys

exp gates admission. It bounds how long a leaked token can be redeemed. It does not terminate anything that has already started, and it is not a session length. Keep tokens short — minutes — and sign one per request or per short burst. A lifetime longer than max_token_lifetime_seconds is refused.

Spending a scope

curl -s "https://<host>/v1/audit-events?limit=5" \
  -H "Authorization: Bearer <token whose scopes include audit:read>"

A token without the scope is refused with 403 scope_not_granted, naming what was needed. A token from another tenant sees none of your events.


5. Rotation, with no dead window

An application may hold several active keys at once. Rotation is register, deploy, retire, and there is no moment in the middle where you hold no working credential.

  1. Generate the next pair on your own machine, as in §2, and send the new public.pem to your administrator.
  2. Ask them to add it on the application's page. Both keys verify from that moment.
  3. Deploy the new private key in your application.
  4. Ask them to revoke the old key.

Before step 4, the console shows when the old key last signed an accepted token. Read it rather than discovering the answer from an outage.

Revocation takes effect immediately. A token signed with a revoked key is refused from that moment, whatever its exp says, with token_key_revoked — a different code from token_key_unknown, so "switched off" and "never heard of it" can be told apart.

Revoking the last active key is permitted, so that a leaked key can be withdrawn without waiting for a replacement to be arranged.

Your application cannot rotate its own keys. No scope grants credential self-management; the operation stays with a workspace administrator.

6. When a request is refused

Every code on this API — not only the ones below — is catalogued with its cause and its remedy in the Error reference.

Every refusal is an RFC 9457 problem document with a stable code, a detail written for a person, a correlation_id, and — where a specific member is at fault — errors[] with a JSON Pointer naming it.

{
  "type": "about:blank",
  "title": "Unauthorized",
  "status": 401,
  "code": "token_expired",
  "detail": "The token expired at 1787699400 (2026-08-28T21:50:00+00:00), which is 3600 seconds ago; this deployment's clock reads 1787703000 (2026-08-28T22:50:00+00:00). Tolerance is 60s either way — if that offset looks like your clock rather than your code, check NTP on the signing host.",
  "correlation_id": "01a04a82-068b-7298-b042-be7b1bbda8dc",
  "errors": [{ "pointer": "/claims/exp", "code": "expired" }]
}

401 — the credential is not usable

Code What happened What to do
token_missing no Authorization: Bearer send one
token_malformed not a three-segment JWS, or a segment is not unpadded base64url check your encoder; segments are base64url without padding, and it is enforced (RFC 7515 §2) — the detail names which of the two faults it was and the call that fixes it: Node Buffer.toString('base64url'), Python base64.urlsafe_b64encode(x).rstrip(b'='), PHP rtrim(strtr(base64_encode($x), '+/', '-_'), '=')
token_algorithm_unsupported alg is not RS256 sign with RS256
token_claim_unknown a header or claim member this API does not define remove it, or fix the spelling; the pointer names it
token_claim_malformed a member of the wrong type, shape or value the detail names the expected type; a quoted number is a string
token_key_unknown no key with that kid for that tenant check kid and tenant_id
token_key_revoked the key was revoked sign with a live key
token_signature_invalid signature does not verify against the registered key the private key you signed with is not the pair of the key that was registered; compare fingerprint_sha256 against the command in §3
token_expired exp is past the detail names both clocks; check NTP
token_not_yet_valid nbf is future same
token_lifetime_excessive exp - iat above the maximum sign short-lived tokens
token_scope_classes_mixed a KYC scope alongside a general one sign two tokens
token_audience_invalid addressed to another deployment use audience from discovery
token_issuer_mismatch iss names a different application than the key belongs to check iss
no_valid_principal no credential at all, or an application credential on a route that takes a browser session only send a credential; if your token works elsewhere, that route is session-only

Those are the codes about the credential itself. The Error reference is the complete list, including the ones that reach a person signing in rather than an application.

403 — the credential is valid, the request is not permitted

Nothing in this group is fixed by rotating a key. These four are about the credential; the Error reference carries the rest, which are about the operation you asked for.

Code What happened
token_tenant_mismatch X-Tenant-Id names a different tenant than the token
tenant_suspended the tenant is suspended; the signature was fine
application_suspended the application is suspended; the signature was fine
scope_not_granted the token does not carry the scope the operation needs; the detail names it

429 — rate limited

Two limiters sit in front of these surfaces, and the headers report whichever one the response passed through.

The one you budget against is per application, published as rate_limit_per_minute in discovery. It runs after your credential is verified, and it sets X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every response it sees, not only on the 429.

In front of the credential check, on every surface that performs one, sits a per-address limiter. It bounds how many credentials an address may fail to verify in a minute. A credential that verifies is not charged to it, so it sits beside your application budget rather than under it: an application making its published rate_limit_per_minute requests from one address never meets it.

The consequence to design for: while an address is over that limit, a credential that would verify is refused too, because the limiter runs before the check and cannot know. If you share an egress address with something that is failing to authenticate, you will feel it — find that credential rather than retrying through it. The detail on the 429 says which limiter refused you.

GET /v1/integrator/discovery and GET /v1/applications/me additionally carry a per-address limiter that reports itself in X-RateLimit-Limit and X-RateLimit-Remaining, which is why those two show a number lower than your application budget.

So: read the headers as your application's budget only on a response to a request that carried a verified credential. Where they appear on any other response — a 401, or GET /v1/integrator/discovery, which needs no credential — they report the per-address limiter instead, and the number will be lower. Most refusals carry no rate-limit headers at all, so their absence tells you nothing. Budget against rate_limit_per_minute from discovery.

GET /v1/applications/me passes through both limiters and reports the per-address number, because the outer one writes last.

X-RateLimit-Reset is an absolute unix timestamp; Retry-After, present on the 429, is a duration in seconds.

A refused request does no work — every mutation runs in one transaction — but it does spend budget. The charge is taken before the operation starts, so a run of refusals counts against you exactly as a run of successes would.

Registration refusals

These are the refusals your administrator sees when registering or revoking a key. The key_material_*, key_algorithm_unsupported and key_too_small rows are 422 and name /public_key; the rest carry the status shown and no pointer.

Code What happened
key_material_missing nothing was supplied
key_material_is_private a private key was sent. It was not stored, not logged and not transmitted onward. Treat it as exposed anyway: delete it, generate a fresh pair, upload the public half
key_material_openssh_format OpenSSH format; the detail gives the conversion
key_material_pkcs1_format PKCS#1 format; the detail gives the conversion
key_material_unreadable not a readable PEM public key
key_algorithm_unsupported not RSA
key_too_small RSA below 2048 bits
key_already_registered (409) that exact key is already registered; the detail names its kid
key_limit_reached (409) the application is at its ceiling of active keys
key_not_found (404) no key with that id belongs to this application
idempotency_key_required (400) creating requests need an Idempotency-Key UUID header
idempotency_key_reused (409) that key was already used with a different body
idempotency_request_in_flight (409) the first request with that key has not finished
key_identifier_conflict (409) an identifier could not be generated for the key; retry

7. Tenancy

A token names one tenant and a set of scopes, and reaches nothing outside either.

This is enforced twice: the API refuses and says why, and the store underneath returns nothing belonging to another tenant, so a fault in the layer above cannot widen the boundary. Your key is looked up inside your tenant's scope, which means a token cannot reach a key outside the tenant it names.

A resource belonging to another tenant answers 404, not 403. The answer tells you nothing about what exists elsewhere.

8. Resolution of last_used_at

last_used_at on a key has one-minute resolution. It is not written on every request, so a key used seconds ago may still show the previous minute. Use it to answer "is anything still signing with this key", which is what it is precise enough for.

Error reference

Every refusal from a surface of this API is an RFC 9457 problem document with a stable code, a detail written for a person, and a correlation_id that joins your response to this deployment's audit trail.

That holds under /v1/ for a request that never reaches a surface too — an address this API does not route, or a method a route does not take. Those answer not_found and http_error respectively, with a correlation_id and without rate-limit headers, because no limiter saw them. Outside /v1/ you are not talking to this API, and an address there answers with an ordinary web page, not a problem document.

{
  "type": "about:blank",
  "title": "Unauthorized",
  "status": 401,
  "code": "token_expired",
  "detail": "The token expired at … which is 3600 seconds ago; this deployment's clock reads … Tolerance is 60s either way — if that offset looks like your clock rather than your code, check NTP on the signing host.",
  "correlation_id": "01a04a82-068b-7298-b042-be7b1bbda8dc",
  "errors": [{ "pointer": "/claims/exp", "code": "expired" }]
}

Branch on code, never on detail. The code is the contract and is locale-invariant; the sentence is written for a reader and may be reworded.

errors[] names the member at fault. Each entry carries a RFC 6901 pointer into what you sent — /participants/2/subject/user_id, not "invalid input" — a code from the vocabulary below, and usually a message written for a person. Branch on pointer and code; the message may be reworded. Every bad entry is reported, not only the first.

Some refusals carry extension members that make them actionable: a join window's window_opens_at, a provisioning failure's cause. They are listed with the code below. RFC 9457 section 3.2 allows this, and a number the deployment already knows should never be something you derive from prose.

A code narrows the problem. Where one code covers more than one fault — token_malformed covers six, download_reference_invalid four — its row names them all, and the detail says which one you met. download_reference_invalid is the exception: its four causes are merged so that an anonymous caller cannot probe which references exist, and its detail does not distinguish them.

Two pairs of codes describe the same remedy from different sides, and their rows say so: token_missing and no_valid_principal both mean "send a credential", and token_tenant_mismatch and tenant_mismatch are the same disagreement seen by an application and by a person.


The credential is not usable

code status what happened what to do
token_missing 401 No Authorization: Bearer header was sent on a surface that takes ONLY an application credential — in practice GET /v1/applications/me. A surface that would also accept a browser session answers no_valid_principal instead, so this code is narrower than it looks and most missing-credential requests get that one. Send one. Two surfaces your application calls need no credential: GET /v1/integrator/discovery and a redeemed artefact download reference. The surfaces a PERSON reaches through a meeting's entry_url take none either — identifying themselves, knocking, recording a consent decision, reporting abuse — and your application does not call those. Everywhere else, treat this code and no_valid_principal as the same instruction.
token_malformed 401 Any of six: the credential is not a three-segment JWS; a segment is empty; a segment carries base64 padding, which is refused because unpadded base64url is enforced (RFC 7515 §2) rather than merely recommended; a segment carries a character outside the base64url alphabet; the header or the payload decodes but is not JSON; or the header carries no kid, which is required. The detail says which fault it is and names the encoder call that fixes it. Padding (=) and a standard encoder's + and / are both refused: Node Buffer.toString("base64url"), Python base64.urlsafe_b64encode(...).rstrip(b"="), PHP rtrim(strtr(base64_encode($x), "+/", "-_"), "=").
token_algorithm_unsupported 401 The header alg is not RS256. Sign with RS256. It is fixed in the verifier rather than read from your header, so there is no algorithm to negotiate.
token_claim_unknown 401 A header or claim member this API does not define. Both sets are closed: the header takes alg, typ and kid, and the claims are the nine listed in discovery. Remove it, or correct the spelling. The JSON Pointer in errors[] names it, and its first segment says which set it was in: /header/… or /claims/….
token_claim_malformed 401 A member of the wrong type, shape or value — and also a required member that is absent, or a scope outside the twelve. A quoted number is a string; an empty scopes array grants nothing. Read the pointer and errors[].code together: the pointer names the member, and the code says which kind of fault it is — required for an absent member, type for the wrong JSON type, format for the wrong shape, unknown for a scope this API does not define.
token_key_unknown 401 No key with that kid is registered for that tenant_id. Check both. The kid came from the key registration and is not derivable from the key material.
token_key_revoked 401 The key was revoked. A distinct code from token_key_unknown so you can tell "switched off" from "never heard of it". Sign with a live key. Which keys are active is on the application's page in the console, which your administrator can read; the key list is not open to an application credential.
token_signature_invalid 401 The signature does not verify against the registered public key. The private key you signed with is not the pair of the public key that was registered. Compare the fingerprint_sha256 the console showed against openssl pkey -pubin -in public.pem -outform DER | openssl dgst -sha256 -r | cut -d' ' -f1.
token_expired 401 exp is in the past, beyond the clock tolerance. The detail names both clocks and the tolerance. If the offset looks like your clock rather than your code, check NTP on the signing host.
token_not_yet_valid 401 nbf is in the future, beyond the clock tolerance. Same as above — the detail names both clocks.
token_lifetime_excessive 401 exp - iat is above max_token_lifetime_seconds from discovery. Mint short-lived tokens: one per request, minutes long. exp gates admission, not session length, so a long one buys nothing but blast radius.
token_scope_classes_mixed 401 A KYC scope alongside a general one. They are two classes and never share a credential. Mint two credentials, one per class.
token_audience_invalid 401 aud addresses a different deployment. Use the audience value from GET /v1/integrator/discovery. Do not hard-code it: a deployment is allowed to differ.
token_issuer_mismatch 401 iss names a different application than the one the signing key belongs to. Set iss to the application identifier, which the console shows on the application's own page beside the tenant identifier. It is neither the kid nor the key's own id.
invalid_credentials 401 A browser sign-in was refused: the one-time code was wrong, expired, already used, or attempted too often. It belongs to the sign-in a person does in a browser, not to an application credential. Ask for a fresh code from the sign-in page. Codes are single-use and short-lived, and one refusal covers all four causes so that an attempt cannot tell a real address from an unknown one.
no_valid_principal 401 One of two: no principal at all was presented, on a route that would accept either a session or an application credential — this is the code most missing-credential requests get, rather than token_missing; or an application credential WAS presented, on a route that accepts a browser session only. Application administration, the member directory, the user surfaces and the admission queue are session-only, and no scope on a token opens them. If your token works elsewhere and is refused here, the route is session-only and no token will do — the getting-started guide lists that set in full, and those routes are a person's to call, in a browser. Otherwise send Authorization: Bearer <signed JWT>. A refused bearer token is never fallen back to a cookie.

The credential is valid, the request is not permitted

code status what happened what to do
step_up_required 403 The session is authenticated but not elevated, and this operation needs elevation. It reaches a person in a browser session, never an application credential. Call POST /v1/auth/step-up and retry. That route is session-only.
scope_not_granted 403 The token is valid; it does not carry the scope this operation needs. The detail names the scope required and lists what you sent. Mint a token carrying it. Rotating the key will not help.
tenant_mismatch 403 A session principal sent X-Tenant-Id for a tenant it holds no active membership in. Use a tenant the signed-in identity belongs to. It is refused rather than silently narrowed to a tenant you did not ask for. (An application credential gets token_tenant_mismatch instead — different principal, different code.)
token_tenant_mismatch 403 X-Tenant-Id names a different tenant than the token's tenant_id. Send the header matching your token, or omit it. A request whose header and credential disagree is refused rather than resolved in either direction.
tenant_suspended 403 The tenant is suspended. The signature was fine. This needs an administrator, not a code change.
application_suspended 403 The application is suspended. The signature was fine. A tenant administrator re-activates it with PATCH /v1/applications/{id}.
forbidden 403 The operation requires a role the caller does not hold — usually tenant_admin. Use an account with the role, or the administrator surface.
role_not_permitted 403 The meeting role you asked for is above the caller's own authority on that occurrence. Ask for a role at or below yours. You get a refusal rather than a token that quietly grants less than you asked for.
subject_not_on_occurrence 403 You asked for a join token for somebody with no standing on that occurrence: not a participant, and not its organiser. This is about who, not about what role — you did not have to request a role for it to happen. Invite them with POST /v1/meetings/{id}/participants (the same subject shape, and meetings:write) and mint again. The roster closes once the occurrence goes live, so on a meeting that has already started that call answers 409 not_editable and inviting is no longer available. The meeting's entry_url works at any point in its life, and is the one door somebody not on the list comes through.
minting_for_another_not_permitted 403 A session principal asked for a join token for somebody other than itself. Minting for another subject needs meeting-moderator authority on that occurrence, or an application credential carrying tokens:mint.
visitor_tier_not_offered 403 A visitor token was asked for on a meeting whose vertical does not admit visitors. A visitor watches and can be promoted to a participant; not every vertical offers that standing. Mint an ordinary participant token. Which verticals admit visitors is a property of the vertical, not of the meeting, so no setting on the meeting will change it.
promotion_not_permitted 403 A promotion was asked for without the authority to grant it. Promotion needs meeting-moderator authority on that occurrence, or an application credential carrying tokens:mint.
registration_required 403 The meeting admits registered participants only. Add the person with POST /v1/meetings/{id}/participants, or turn registration off with PATCH /v1/meetings/{id} and a body of {"registration": {"enabled": false}}.
vertical_not_licensed 403 The tenant is not entitled to that vertical. Use a vertical the tenant holds, or have the entitlement added.
organiser_not_permitted 403 A signed-in user named somebody else as organiser without the authority to do so. Naming another person as organiser needs the tenant_admin role. Omit the member to become the organiser yourself.

The request is malformed

code status what happened what to do
validation_failed 422 One or more members of the request body are missing, wrong, or not defined by this API. A member this API does not define is refused, never ignored. Read errors[]: each entry has an RFC 6901 JSON Pointer into the body you sent. Every bad entry is reported, not only the first, so fixing a list of fifty does not take fifty round trips.
rule_outside_subset 422 A recurrence rule outside the supported subset. freq is daily, weekly or monthly, with exactly one of until / count. Use the subset. A rule outside it is refused rather than read as something narrower, so a series is never created on terms you did not ask for.
idempotency_key_required 400 A creating POST arrived without an Idempotency-Key UUID header. Send one. A fresh UUID per intention; the same one on a retry.
tenant_header_invalid 400 X-Tenant-Id is absent or is not a UUID on a surface that requires it. Send the tenant UUID. Application credentials carry the tenant in the token and may omit the header entirely.
organiser_required 422 An application credential created a meeting without naming an organiser at all. Your application is not a person, and a meeting needs an owner. Send organiser. It does NOT have to be an account here: {"type": "guest", "name": …, "email": …} names one of your own users and makes them the meeting's owner, the ICS ORGANIZER, and the address the calendar copy goes to. (A signed-in user may omit it and becomes the organiser themselves.)
organiser_not_member 422 You named a type: user organiser (or an organiser_user_id) who is not an active member of this tenant. Either name someone who is, or stop naming a member at all: organiser: {"type": "guest", "name": …, "email": …} needs no account here and is refused for no such reason. If you do want a member, GET /v1/directory lists them, but it takes a browser session, so an application credential cannot read it.
origin_not_permitted 422 A framing_origins entry is not a well-formed origin. An origin is scheme, host and optional port — RFC 6454 — and it is exactly what a browser compares against the room's frame-ancestors policy. A path, a trailing slash, a wildcard, credentials or a plaintext scheme off loopback are all refused. The errors array names every bad entry with its index and a sentence saying what is wrong with it, so you do not have to fix them one request at a time. Register https://app.example.com, not https://app.example.com/ and not https://*.example.com.
nothing_to_change 422 A PATCH /v1/applications/{id} that named no field this deployment would change. It belongs to that surface only. Send status, framing_origins, or both.
target_not_permitted 422 A webhook URL this deployment will not deliver to: not https, a port other than 443/8443, credentials in the URL, a name that resolves to a loopback/private/link-local/metadata address, or a name that does not resolve at all — reserved domains (.example, .invalid, .test, .localhost) never do. The detail says which of those it was. The same 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.
event_unknown 422 A subscription named an event this deployment does not emit. Refused at registration, with the offending index. Use a name or dotted prefix from GET /v1/integrator/discovery → webhooks.events. A typo must not become a subscription that never fires and never says why.

The resource is not in a state that allows this

code status what happened what to do
use_sign_in 409 A guest tried to enter a meeting with an address that belongs to an account. It is refused so that a guest row can never shadow a real identity. Sign in and join as yourself. This is not the code for a route that wants a browser session; that is no_valid_principal, in the credential section.
idempotency_key_reused 409 That key was already used with a different request body. A key names one intention. Use a new key for a new request. If you meant to retry, send the body byte-for-byte as before — echoing back a field the server normalised is the usual cause.
idempotency_request_in_flight 409 The first request with that key has not finished. Wait and retry with the same key; you will get the stored response. Nothing was created by the second attempt.
not_found 404 No such resource within this tenant — a resource belonging to another tenant answers 404, never 403 — OR an address this API does not route at all. The detail says which of the two. Check the id, then the path. The answer tells you nothing about what exists in another tenant.
not_joinable_yet 409 The join window has not opened. A scheduled occurrence admits nobody before it. The response carries window_opens_at and window_closes_at. Mint the link when the person is about to join, not when you send the invitation — send entry_url for that.
not_joinable_cancelled 409 The occurrence was cancelled. Schedule a new occurrence. A token already in someone's hands stays valid until it expires.
not_joinable_finished 409 The occurrence is completed or expired. Schedule a new occurrence.
not_joinable_window_closed 409 Still scheduled, but past the end of its window. The response carries window_closes_at. Schedule a new occurrence.
not_cancellable 409 The occurrence cannot be cancelled from its current state. A live meeting is ended, not cancelled. A completed, expired or already-cancelled one is finished. They are separate codes because one code for both would tell you neither.
not_endable 409 The occurrence cannot be ended from its current state. See not_cancellable.
not_editable 409 Only a scheduled occurrence can be edited. Cancel it and schedule another.
not_promotable 409 The participant holds no live visitor token to supersede. Mint a join token for them first.
capacity_reached 409 The occurrence is at capacity. Capacity is a property of the meeting's vertical and is not a member you set: meeting admits 20, learning 100, conference and webinar 500. Wait for someone to leave, or schedule under a vertical with a larger capacity. The capacity member of a meeting is read-only and reports the band in force.
external_ref_taken 409 Another participant on this occurrence already carries the external_ref you sent. Within one meeting a reference names one person — that is what makes it usable for matching our rows to yours. Send the reference that belongs to this person, or none at all. If you meant the person who already holds it, they are on the meeting: GET /v1/meetings/{id}/participants shows every roster row with its external_ref.
participant_exists 409 That subject is already a participant on the occurrence. Nothing to do: they are already invited. To change their role, PATCH /v1/meetings/{id}/participants/{participantId}.
user_exists 409 The address already belongs to a member of this tenant. Use the existing member, or a different address.
application_exists 409 An application with that name already exists in this tenant. Choose another name, or use the one that exists.
delivery_not_replayable 409 That webhook delivery has not finished yet. Wait for it to settle, then redeliver. Replay re-sends the recorded transition and never re-runs the operation behind it.
endpoint_limit_reached 409 The tenant already holds the maximum number of webhook endpoints, published as webhooks.max_endpoints in discovery. Delete one, or subscribe an endpoint you already have to more events — one endpoint can carry many event types.

Registering or revoking a key

code status what happened what to do
key_material_missing 422 No key material was supplied. Send public_key as a PEM string.
key_material_is_private 422 A private key was sent. It was not stored, not logged and not transmitted onward: it is refused before anything writes it down. Treat that key as exposed anyway: delete it, generate a fresh pair, and upload the public half (openssl rsa -in private.pem -pubout -out public.pem).
key_material_openssh_format 422 OpenSSH format (ssh-rsa AAAA…), not PEM SubjectPublicKeyInfo. Convert it: ssh-keygen -e -m PKCS8 -f id_rsa.pub > public.pem. The detail gives you the same command.
key_material_pkcs1_format 422 PKCS#1 (BEGIN RSA PUBLIC KEY), not SubjectPublicKeyInfo. Convert it: openssl rsa -RSAPublicKey_in -in pkcs1.pem -pubout -out public.pem.
key_material_unreadable 422 Not a readable PEM public key. Check you sent the whole file, banners included, with its newlines intact. JSON-encoding a PEM without preserving \n is the usual cause.
key_algorithm_unsupported 422 The key material is readable but is not an RSA key — an EC or Ed25519 public key, most often. Generate RSA: openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048.
key_too_small 422 An RSA key below the 2048-bit floor this deployment accepts. Generate at least 2048. There is no upper limit, and 4096 verifies identically.
key_already_registered 409 That exact key is already registered for this application. The detail names the existing kid. Use the kid you already have. If it was revoked, generate a fresh pair rather than re-registering it.
key_limit_reached 409 The application is at its ceiling of active keys (max_active_keys_per_application in discovery). Revoke a key you no longer sign with. Check last_used_at first — it tells you whether anything is still signing with it.
key_not_found 404 No key with that id belongs to this application. The path takes the key's own identifier, which is NOT the kid your token header carries. Revoke from the application's page in the console, which holds both identifiers. Check the application id as well.
key_identifier_conflict 409 A key identifier could not be generated for this registration. Retry the registration. Report it with the correlation_id if it repeats.

You are going too fast

code status what happened what to do
rate_limited 429 Too many requests, from one of two limiters. The one you budget against is per application, published as rate_limit_per_minute in discovery, and it runs after your credential is verified. In front of the credential check, on every surface that performs one, a per-address limiter bounds how many credentials an address may FAIL to verify in a minute; a credential that verifies is not charged to it. The detail says which of the two refused you. Back off for the interval in Retry-After. If the detail names failed verifications, something at your address is presenting a credential this deployment will not accept — find it rather than retrying through it, because while the address is over, a credential that WOULD verify is refused too. Budget against rate_limit_per_minute from discovery, and read the rate-limit headers as your application's budget only on a response to a request that carried a verified credential. A refused request has done no work.

The address or the method is wrong

code status what happened what to do
http_error 405 The address exists but does not accept that method. It is raised before any surface of this API is reached, so it carries no rate-limit headers. An address nothing routes at all answers not_found, not this. The Allow header on the response names the methods that address does take.

This deployment could not do it

code status what happened what to do
room_not_provisioned 503 The room's admission policy could not be written, so the join is refused (fail-closed). The cause member says which of three: unconfigured, unreachable, or refused. Branch on cause. unconfigured is a deployment that cannot provision rooms, and retrying will not help. unreachable is worth a retry. refused needs an operator. Nothing was created and no token was issued in any of the three.
join_signing_unavailable 503 This deployment holds no usable join-token signing key. Nothing in your request caused this and retrying will not clear it. Report it to whoever operates the deployment, with the correlation_id.
artefact_storage_unavailable 503 The artefact store could not be reached. Retry. Report it with the correlation_id if it persists: nothing in your request can clear it.
internal_error 500 An unhandled fault. The request was not at fault and retrying the same request will not change the outcome. Report it with the correlation_id, which joins your response to this deployment's audit trail, and with the steps that produced it.

Redeeming an artefact download reference

code status what happened what to do
download_reference_invalid 404 The reference is unknown, expired, already redeemed, or revoked. One code covers all four, deliberately: this route faces the open internet with no credential, and distinguishing them would let an anonymous caller probe which references exist. Mint a fresh one with POST /v1/artefacts/{id}/download-reference. References are single-use and minutes long: fetch one when you are ready to read the bytes, rather than storing it.
artefact_bytes_missing 410 The reference was good and the bytes are gone from the store. Retrying will not recover them. Report it with the correlation_id and the artefact id.

errors[].code — the field-level vocabulary

When a specific member of your request is at fault, errors[] names it with an RFC 6901 JSON Pointer and one of these. They are deliberately general: the pointer says where; this says what kind. Pairing a specific location with a general kind is what makes a validation error machine-readable without inventing a code per field.

Two of them — target_not_permitted and event_unknown — are spelled the same as a top-level code. Position tells you which vocabulary you are reading: a string inside an errors[] entry is from this table, and a string at the top level of the document is from the tables above.

An errors[] entry usually carries a third member, message, written for a person. Branch on pointer and code; the message may be reworded.

errors[].code what it means
required The member is absent and is not optional.
type Present, but not a usable value of the expected type: the wrong JSON type — a quoted number is a string — or an empty array or empty string where the member must carry something.
format The right type, in the wrong shape (a malformed address, a non-UUID).
invalid The right type and shape, but not a value this API accepts.
unknown A member this API does not define, or a value outside a closed set — an undefined scope, or a kid naming no registered key. Refused rather than ignored.
unsupported A recognised value this deployment does not support.
duplicate The same thing twice, or something already registered.
mismatch Two members that must agree do not.
minimum Below a numeric floor — an RSA key under 2048 bits.
insufficient Valid, but not enough — a token missing a scope the operation needs.
expired exp is in the past, beyond the clock tolerance.
not_yet_valid nbf is in the future, beyond the clock tolerance.
lifetime_excessive exp - iat exceeds this deployment's maximum token lifetime.
revoked The key named was revoked.
class_conflict KYC and general scopes on one token. They never share a credential.
not_member The user named is not an active member of this tenant.
not_permitted The caller may not name that value, though the value itself is fine. It sits beside a pointer; the top-level forbidden is about the operation rather than a member of it.
unloadable Key material that could not be read as a PEM public key.
limit_invalid A paging limit outside the accepted range.
target_not_permitted The webhook URL is one this deployment will not deliver to.
not_an_origin Not a well-formed origin: scheme, host and optional port, nothing else.
empty A request body that names nothing this deployment would change.
event_unknown An event name this deployment does not emit.

Headers on a refusal

X-Correlation-Id carries the same value as the body's correlation_id, on every response. Send one of your own and it is echoed back, so a trace joins at both ends; send none and this deployment generates one. It is the value to quote when you report a fault.

Allow accompanies a 405 and names the methods that address does take.

Retry-After accompanies a 429. Idempotency-Replayed: true accompanies the stored answer to a repeated Idempotency-Key.

Rate-limit headers

Two limiters sit in front of these surfaces, and the headers report whichever one the response passed through. Reading the wrong one is the mistake to avoid.

The per-application limiter is the one you budget against. Its ceiling is rate_limit_per_minute from GET /v1/integrator/discovery. It runs after your credential is verified, and it sets X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every response it sees, not only on the 429 — so you can back off before you are refused rather than after.

A per-address limiter runs in front of the credential check, on every surface that verifies one. It bounds how many credentials an address may FAIL to verify in a minute. A credential that verifies is not charged to it, so it sits beside your application budget rather than under it: an application making its published rate_limit_per_minute requests from one address never meets it.

Two things follow, and the second is the one to design for:

  • A run of refusals is bounded. Once an address is over, every request from it is 429 rate_limited with a Retry-After, and the detail says the limit counts failed verifications.
  • While it is over, a credential that WOULD verify is refused too — the limiter runs before the check, so it cannot know. If your application shares an egress address with something that is failing to authenticate, you will feel it. Fix the failing credential rather than retrying through it.

Its ceiling is separate from the published one and is not in discovery. GET /v1/integrator/discovery and GET /v1/applications/me additionally carry a per-address limiter that reports itself in X-RateLimit-Limit and X-RateLimit-Remaining, which is why those two surfaces show a lower number than your application budget.

What follows from that ordering:

  • On a response to a request that carried a verified credential, the headers are your application's budget.
  • Where they appear on any other response — a 401, or GET /v1/integrator/discovery, which needs no credential — they report the per-address limiter, and the number will be lower than rate_limit_per_minute. Most refusals carry no rate-limit headers at all, so their absence tells you nothing.
  • GET /v1/applications/me passes through both, and reports the per-address number, because the outer limiter writes last.
  • A request that never reached a surface at all carries no rate-limit headers.

So: budget against rate_limit_per_minute from discovery, and treat the headers as your own budget only on responses to authenticated requests.

X-RateLimit-Reset is an absolute unix timestamp; Retry-After, present on the 429, is a duration in seconds.

What a refusal leaves behind

No work. Every mutation runs in one transaction, so a refused request leaves no half-created meeting and no invitation sent for a meeting that does not exist.

One thing a refused request does spend is rate-limit budget: it is taken before the operation starts, so a run of refusals counts against you exactly as a run of successes would. X-RateLimit-Remaining falls on a 401.

The one thing to know: a request that failed spends no idempotency key. Fix the body and send it again with the same key.

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:

  • At most 200 characters. Longer is 422, pointing at the entry. We refuse rather than truncate, because a truncated identifier matches nothing in your system while looking as though it should.
  • Unique within one meeting. Two people on the same meeting may not share a reference — that is the ambiguity the member exists to remove. A duplicate is 422, naming the second entry by position. Any number of people may carry no reference at all.

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.

  • The same key with the same body returns the original response unchanged: same status, same body, same ids, plus Idempotency-Replayed: true. Nothing is created a second time.
  • The same key with a different body is 409 idempotency_key_reused. The key names one intention, and answering with the first request's meeting would hide the mistake behind an apparent success.
  • The same key while the first request is still running is 409 idempotency_request_in_flight. Nothing was created by the second attempt. Wait and retry; you will get the stored response.
  • A request that failed spends no key. Nothing was created, so the same key is free. Fix the body and send it again with the same key.

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.

  • The person must already be on the meeting. A join token names somebody with standing on that occurrence: a participant, or its organiser. Minting for anyone else is 403 subject_not_on_occurrence, whether or not you asked for a role. Invite them first with POST /v1/meetings/{id}/participants.

  • The first mint takes the occurrence live, and that closes the roster. scheduling.meeting_started fires on it, and from that moment POST /v1/meetings/{id}/participants answers 409 not_editable. Invite everybody before you mint for anybody; afterwards, entry_url is the way in for somebody who is not on the list.

  • One link, one person, one meeting. The token's room claim is that occurrence and nothing else. There is no wildcard.

  • It is short-lived — ten minutes on this deployment. It is a credential to get in, so mint it when the person is about to join, not when you send the invitation.

  • There is a join window. A scheduled occurrence becomes joinable ten minutes before its start and stops being joinable at its end. Create a meeting for next week, ask for a join link, and you get 409 not_joinable_yet carrying window_opens_at and window_closes_at. For a link to put in an invitation, use entry_url.

  • Capabilities are decided here, not requested. What a participant can do in the room comes from the meeting's vertical and their standing on it. A role above your caller's authority is 403 role_not_permitted rather than a token that quietly grants less than you asked for.

  • The room is prepared before the token is signed. Where it cannot be, you get 503 room_not_provisioned and no token: you never receive a link into a room whose admission policy could not be written. This is the only call in this document that depends on the room being reachable; creating, editing and cancelling meetings do not.

    It carries a cause: unconfigured (this deployment cannot provision rooms, and retrying will not help), unreachable (worth a retry), refused (the request was answered and the policy was not accepted, which needs an operator).


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:

  • The whole secret is the key, whsec_ prefix included. It is part of the key, not a label. Strip it and your digest will never match.
  • The tolerance is 300 seconds on this deployment, published at GET /v1/integrator/discovery → webhooks.signature_tolerance_seconds.

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

  • De-duplicate on id. Delivery is at-least-once, and the same delivery can arrive twice.
  • Order on occurred_at. That is the transition's own time. Arrival order is not causal order: a meeting_ended can reach you before the meeting_started that precedes it. A consumer that trusts arrival order corrupts its own state.
  • sequence detects a delivery you never received. It is per endpoint, starts at 1, and never skips. Seen 41 and 43? Fetch 42 from GET /v1/webhooks/{id}/deliveries. It is not an ordering: two deliveries are dispatched concurrently, and a higher sequence can arrive first.
  • Retries keep the sequence. X-Svc-Delivery-Attempt counts the tries; sequence identifies the delivery.

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.

  • No anonymous join. Every subject is named. There is no path that issues a token to nobody.
  • scheduling.meeting_started fires when the first join token is minted, not when a person actually enters the room. It means that somebody has asked for a way in. meeting_ended is the explicit end, or the platform closing a meeting whose window has passed; data.ended_by says which.
  • No per-occurrence RSVP on a recurring series. An invitee answers the first occurrence's invitation. Later occurrences carry their participant rows and no separate invitation.
  • The event catalogue in /v1/integrator/discovery is the complete list. There is no event for moderation, for credential changes, or per join.