Skip to content

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:

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:

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.