Skip to content

Getting your first meeting running

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

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

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

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


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

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

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

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

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

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

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


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

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

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

Ask them to register your application

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

What comes back to you:

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

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

Ask them to add your public key

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

What comes back to you:

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

Check that the deployment holds the key you meant:

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

That prints the same value the console shows.

Ask them to register the origins your pages are served from

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

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

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

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

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

Ask them to register a webhook endpoint

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

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

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

What you should hold at the end of part 2

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

You have held the private key the whole time.

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

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


Part 3. In your application: build the integration

3.1 Read what this deployment expects

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

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

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

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

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

3.2 Sign a token

Header

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

Claims

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3.3 Check the credential

Do this before anything else depends on it.

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

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

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

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

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

3.4 Create a meeting

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

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

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

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

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

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

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

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

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

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

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

Omitting the organiser altogether is 422 organiser_required.

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

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

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

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

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

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

Seven rules govern this call.

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:

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 full recipe, with a working implementation, is in Running a meeting from your own application.

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

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

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

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

3.8 Read back what the meeting produced

There are two ways to learn that an artefact exists.

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

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

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

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

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

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

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

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

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

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

Fetching the bytes is the same two calls either way.

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

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

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

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

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


Rotating a key

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

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

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

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

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

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

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


When a request is refused

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

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

Three things are true of every refusal:

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

Where to go next

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

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