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.