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.