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.
- Sign a token with your private key, and check it works before anything depends on it.
- Create a meeting and invite people to it.
- Get one person in by minting their join link.
- Put the room in your page, or send them the meeting's own entry link.
- Be told what happened by subscribing a webhook endpoint.
- 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
- the deployment address, which you already had;
- the application identifier;
- the tenant identifier;
- the
kidof your key; - 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.
3.5 Get one person a join link
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 withPOST /v1/meetings/{id}/participants, which takes the samesubjectshape and needsmeetings:write. - The first mint takes the occurrence live, and that closes the roster.
From that moment
POST /v1/meetings/{id}/participantsanswers409 not_editable. Invite everybody before you mint for anybody; afterwards,entry_urlis 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, carryingwindow_opens_atandwindow_closes_atso 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_provisionedand no token is issued. Thecausemember says which of three:unconfigured,unreachable,refused. - A role may be asked for, and is decided here. The body takes an optional
roleofmoderator,participantorvisitor; omit it and the person's standing on the occurrence decides. A role above your caller's own authority is403 role_not_permittedrather than a token that quietly grants less than you asked for, and avisitoron a vertical that admits none is403 visitor_tier_not_offered. Promote a visitor afterwards withPOST /v1/meetings/{id}/participants/{participantId}/promote, which needstokens: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.jsfrom<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.
- Generate the next pair on your own machine, exactly as in part 1, and send
the new
public.pemto your administrator. - Ask them to add it on the application's page. Both keys verify from that moment.
- Deploy the new private key in your application.
- 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:
- Branch on
code, never ondetail. The code is the contract. - 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
detailsays which one you met. - 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.
- Generate the next pair on your own machine, as in §2, and send the new
public.pemto your administrator. - Ask them to add it on the application's page. Both keys verify from that moment.
- Deploy the new private key in your application.
- 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_limitedwith aRetry-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, orGET /v1/integrator/discovery, which needs no credential — they report the per-address limiter, and the number will be lower thanrate_limit_per_minute. Most refusals carry no rate-limit headers at all, so their absence tells you nothing. GET /v1/applications/mepasses 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.
3. Get someone a join link
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 withPOST /v1/meetings/{id}/participants. -
The first mint takes the occurrence live, and that closes the roster.
scheduling.meeting_startedfires on it, and from that momentPOST /v1/meetings/{id}/participantsanswers409 not_editable. Invite everybody before you mint for anybody; afterwards,entry_urlis 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_yetcarryingwindow_opens_atandwindow_closes_at. For a link to put in an invitation, useentry_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_permittedrather 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_provisionedand 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: ameeting_endedcan reach you before themeeting_startedthat precedes it. A consumer that trusts arrival order corrupts its own state. sequencedetects a delivery you never received. It is per endpoint, starts at 1, and never skips. Seen 41 and 43? Fetch 42 fromGET /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-Attemptcounts the tries;sequenceidentifies 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_startedfires 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_endedis the explicit end, or the platform closing a meeting whose window has passed;data.ended_bysays 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/discoveryis the complete list. There is no event for moderation, for credential changes, or per join.