Authenticating your application
This is how your application proves who it is to this platform. There is no user, no login and no session involved. You generate a key pair on your own machine, your administrator registers the public half, and you sign a short-lived token with the private half.
This platform never generates, transmits, holds or downloads your private key.
No endpoint has a field for one and no response returns one. A private key
pasted into the key-registration form is refused before it is stored, as
key_material_is_private.
1. Find out what this deployment expects
curl -s https://<host>/v1/integrator/discovery
{
"audience": "intouch-integrator-api",
"algorithm": "RS256",
"claims": ["iss","sub","aud","tenant_id","scopes","exp","nbf","iat","jti"],
"required_claims": ["iss","aud","tenant_id","scopes","exp"],
"scopes": {
"tokens:mint": "Mint a join token for an occurrence, and promote a visitor.",
"meetings:read": "Read scheduling resources.",
"meetings:write": "Create a meeting or a recurring series.",
"artefacts:read": "Browse the artefact library.",
"recordings:control": "Start and stop recording.",
"consents:read": "Read consent receipts and export the evidence report.",
"entitlements:read": "Read the tenant's effective entitlements.",
"usage:read": "Read metering and usage.",
"audit:read": "Query and export the tenant audit log; read the isolation attestation.",
"webhooks:manage": "Manage webhook endpoints.",
"kyc:manage": "The KYC operating surface.",
"kyc:evidence": "The KYC evidence surface."
},
"kyc_scopes": ["kyc:manage", "kyc:evidence"],
"clock_skew_seconds": 60,
"max_token_lifetime_seconds": 3600,
"max_active_keys_per_application": 5,
"rate_limit_per_minute": 120
}
No credential is needed for this call. Read these values rather than hard-coding them: a deployment is allowed to differ, and this is where it says so.
2. Generate a key pair on your own machine
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem
openssl rsa -in private.pem -pubout -out public.pem
chmod 600 private.pem
private.pem stays with you. It never leaves your infrastructure, and nothing
in this document asks you to send it.
RSA, 2048 bits or more. RS256 is the only algorithm this deployment verifies,
and it is fixed in the verifier rather than read from your token's header, so
there is no algorithm for an attacker to negotiate downwards.
If you already hold a key in another format, the refusal names the format you sent and gives the conversion:
| What you have | Conversion |
|---|---|
OpenSSH (ssh-rsa AAAA…) |
ssh-keygen -e -m PKCS8 -f id_rsa.pub > public.pem |
PKCS#1 (BEGIN RSA PUBLIC KEY) |
openssl rsa -RSAPublicKey_in -in pkcs1.pem -pubout -out public.pem |
3. Ask your administrator to register the application and the key
A workspace administrator signs in at https://<host>/app, opens Developer
console, and registers an application by name. The screen answers with the
application identifier, which is the iss claim of every token you sign.
On that application's page, Add a public key: they paste the contents of
public.pem. The screen answers with two values to send back to you.
| Value | What you do with it |
|---|---|
kid, the key identifier |
Goes in your token header. It is not derivable from the key material, and the only place it comes back is authenticated_with.kid on GET /v1/applications/me — which needs a token that already carries it. |
fingerprint_sha256 |
Check it against your own file. |
openssl pkey -pubin -in public.pem -outform DER | openssl dgst -sha256 -r | cut -d' ' -f1
That prints the same value as fingerprint_sha256.
Registering an application and registering a key are separate acts on separate resources, because rotation is register, deploy, retire: an application holds several live keys at once, so there is no moment in the middle where you hold no working credential. See §5.
Both are administrator operations, in a browser session. A credential cannot register a credential.
4. Sign a token
Header
{ "alg": "RS256", "typ": "JWT", "kid": "k_9f3c1d2e4a5b6c7d8e9f0a1b2c3d4e5f" }
Claims
{
"iss": "<application id>",
"aud": "<the audience from discovery>",
"tenant_id": "<tenant id>",
"scopes": ["audit:read"],
"exp": 1787700000,
"iat": 1787699700,
"jti": "<a unique string>"
}
aud is shown as a placeholder. On this deployment discovery answers
intouch-integrator-api; read the value from discovery rather than copying it
from here, because a deployment is allowed to differ.
| Claim | Required | Notes |
|---|---|---|
iss |
yes | your application's UUID |
aud |
yes | exactly the audience from discovery |
tenant_id |
yes | the tenant this application belongs to |
scopes |
yes | a non-empty array from the twelve in discovery |
exp |
yes | integer seconds since the epoch |
sub |
no | if you send it, it must equal iss |
nbf |
no | integer seconds |
iat |
no | integer seconds |
jti |
no | non-empty string |
The header takes alg, typ and kid, and nothing else. alg and kid
are required; a token whose header carries no kid is refused as
token_malformed. typ is optional, and where you send it the only accepted
value is "JWT". A header member this API does not define is refused as
token_claim_unknown at /header/….
The claim set is closed. Any other member is refused, by name, with a JSON Pointer to it. A misspelled claim, or one nested inside a wrapper object, is an error rather than a token that works and silently does less than you intended.
A required claim that is absent is token_claim_malformed with
errors[].code: required, not a separate code. Read errors[].code alongside
the pointer: required for absent, type for the wrong JSON type, format
for the wrong shape, unknown for a scope this API does not define.
The scope set is closed too. There are exactly twelve, listed in discovery. A value outside them is refused with its index rather than ignored.
KYC scopes never share a credential with the rest. kyc:manage and
kyc:evidence are one class; the other ten are another. A token naming both
classes is refused with token_scope_classes_mixed. Sign two tokens.
Send it as an ordinary bearer credential:
curl -s https://<host>/v1/applications/me \
-H "Authorization: Bearer <the signed JWT>"
{
"application": { "id": "…", "tenant_id": "…", "name": "orders-service", "status": "active", "framing_origins": [], "created_at": "…" },
"authenticated_with": { "kid": "k_9f3c…", "fingerprint_sha256": "8f2b…" },
"scopes": ["audit:read"]
}
scopes in that response is what your token actually carries. Check it before
anything else depends on the credential.
Fix one fault at a time. The credential is checked in a fixed order: the
three-segment shape; then the header and claims — which members they carry, and
the values of alg, typ, sub, tenant_id and scopes; then the key named
by kid; then the signature; and last the values of aud, exp and nbf.
The first failure is the one reported, so until a registered kid resolves, a
wrong audience, an expired token and a bad signature all read as
token_key_unknown — but a bad sub or a malformed tenant_id does not, and
is reported straight away.
This route enforces no particular scope: a credential identifying itself is not
a grant over a resource, and you should be able to test your key before you
have decided anything else. It does still need a token naming at least one
scope. An empty scopes array is refused with token_claim_malformed at
/claims/scopes.
You may send X-Tenant-Id. If you do, it must match the token's tenant_id; a
request whose header and credential disagree is refused rather than resolved in
either direction.
What token lifetime buys
exp gates admission. It bounds how long a leaked token can be redeemed. It
does not terminate anything that has already started, and it is not a session
length. Keep tokens short — minutes — and sign one per request or per short
burst. A lifetime longer than max_token_lifetime_seconds is refused.
Spending a scope
curl -s "https://<host>/v1/audit-events?limit=5" \
-H "Authorization: Bearer <token whose scopes include audit:read>"
A token without the scope is refused with 403 scope_not_granted, naming what
was needed. A token from another tenant sees none of your events.
5. Rotation, with no dead window
An application may hold several active keys at once. Rotation is register, deploy, retire, and there is no moment in the middle where you hold no working credential.
- Generate the next pair on your own machine, as in §2, and send the new
public.pemto your administrator. - Ask them to add it on the application's page. Both keys verify from that moment.
- Deploy the new private key in your application.
- Ask them to revoke the old key.
Before step 4, the console shows when the old key last signed an accepted token. Read it rather than discovering the answer from an outage.
Revocation takes effect immediately. A token signed with a revoked key is
refused from that moment, whatever its exp says, with token_key_revoked — a
different code from token_key_unknown, so "switched off" and "never heard of
it" can be told apart.
Revoking the last active key is permitted, so that a leaked key can be withdrawn without waiting for a replacement to be arranged.
Your application cannot rotate its own keys. No scope grants credential self-management; the operation stays with a workspace administrator.
6. When a request is refused
Every code on this API — not only the ones below — is catalogued with its cause and its remedy in the Error reference.
Every refusal is an RFC 9457 problem document with a stable code, a detail
written for a person, a correlation_id, and — where a specific member is at
fault — errors[] with a JSON Pointer naming it.
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401,
"code": "token_expired",
"detail": "The token expired at 1787699400 (2026-08-28T21:50:00+00:00), which is 3600 seconds ago; this deployment's clock reads 1787703000 (2026-08-28T22:50:00+00:00). Tolerance is 60s either way — if that offset looks like your clock rather than your code, check NTP on the signing host.",
"correlation_id": "01a04a82-068b-7298-b042-be7b1bbda8dc",
"errors": [{ "pointer": "/claims/exp", "code": "expired" }]
}
401 — the credential is not usable
| Code | What happened | What to do |
|---|---|---|
token_missing |
no Authorization: Bearer |
send one |
token_malformed |
not a three-segment JWS, or a segment is not unpadded base64url | check your encoder; segments are base64url without padding, and it is enforced (RFC 7515 §2) — the detail names which of the two faults it was and the call that fixes it: Node Buffer.toString('base64url'), Python base64.urlsafe_b64encode(x).rstrip(b'='), PHP rtrim(strtr(base64_encode($x), '+/', '-_'), '=') |
token_algorithm_unsupported |
alg is not RS256 |
sign with RS256 |
token_claim_unknown |
a header or claim member this API does not define | remove it, or fix the spelling; the pointer names it |
token_claim_malformed |
a member of the wrong type, shape or value | the detail names the expected type; a quoted number is a string |
token_key_unknown |
no key with that kid for that tenant |
check kid and tenant_id |
token_key_revoked |
the key was revoked | sign with a live key |
token_signature_invalid |
signature does not verify against the registered key | the private key you signed with is not the pair of the key that was registered; compare fingerprint_sha256 against the command in §3 |
token_expired |
exp is past |
the detail names both clocks; check NTP |
token_not_yet_valid |
nbf is future |
same |
token_lifetime_excessive |
exp - iat above the maximum |
sign short-lived tokens |
token_scope_classes_mixed |
a KYC scope alongside a general one | sign two tokens |
token_audience_invalid |
addressed to another deployment | use audience from discovery |
token_issuer_mismatch |
iss names a different application than the key belongs to |
check iss |
no_valid_principal |
no credential at all, or an application credential on a route that takes a browser session only | send a credential; if your token works elsewhere, that route is session-only |
Those are the codes about the credential itself. The Error reference is the complete list, including the ones that reach a person signing in rather than an application.
403 — the credential is valid, the request is not permitted
Nothing in this group is fixed by rotating a key. These four are about the credential; the Error reference carries the rest, which are about the operation you asked for.
| Code | What happened |
|---|---|
token_tenant_mismatch |
X-Tenant-Id names a different tenant than the token |
tenant_suspended |
the tenant is suspended; the signature was fine |
application_suspended |
the application is suspended; the signature was fine |
scope_not_granted |
the token does not carry the scope the operation needs; the detail names it |
429 — rate limited
Two limiters sit in front of these surfaces, and the headers report whichever one the response passed through.
The one you budget against is per application, published as
rate_limit_per_minute in discovery. It runs after your credential is
verified, and it sets X-RateLimit-Limit, X-RateLimit-Remaining and
X-RateLimit-Reset on every response it sees, not only on the 429.
In front of the credential check, on every surface that performs one, sits a
per-address limiter. It bounds how many credentials an address may fail
to verify in a minute. A credential that verifies is not charged to it, so it
sits beside your application budget rather than under it: an application making
its published rate_limit_per_minute requests from one address never meets it.
The consequence to design for: while an address is over that limit, a
credential that would verify is refused too, because the limiter runs before
the check and cannot know. If you share an egress address with something that
is failing to authenticate, you will feel it — find that credential rather than
retrying through it. The detail on the 429 says which limiter refused you.
GET /v1/integrator/discovery and GET /v1/applications/me additionally carry
a per-address limiter that reports itself in X-RateLimit-Limit and
X-RateLimit-Remaining, which is why those two show a number lower than your
application budget.
So: read the headers as your application's budget only on a response to a
request that carried a verified credential. Where they appear on any other
response — a 401, or GET /v1/integrator/discovery, which needs no
credential — they report the per-address limiter instead, and the number will
be lower. Most refusals carry no rate-limit headers at all, so their absence
tells you nothing. Budget against rate_limit_per_minute from discovery.
GET /v1/applications/me passes through both limiters and reports the
per-address number, because the outer one writes last.
X-RateLimit-Reset is an absolute unix timestamp; Retry-After, present on
the 429, is a duration in seconds.
A refused request does no work — every mutation runs in one transaction — but it does spend budget. The charge is taken before the operation starts, so a run of refusals counts against you exactly as a run of successes would.
Registration refusals
These are the refusals your administrator sees when registering or revoking a
key. The key_material_*, key_algorithm_unsupported and key_too_small rows
are 422 and name /public_key; the rest carry the status shown and no
pointer.
| Code | What happened |
|---|---|
key_material_missing |
nothing was supplied |
key_material_is_private |
a private key was sent. It was not stored, not logged and not transmitted onward. Treat it as exposed anyway: delete it, generate a fresh pair, upload the public half |
key_material_openssh_format |
OpenSSH format; the detail gives the conversion |
key_material_pkcs1_format |
PKCS#1 format; the detail gives the conversion |
key_material_unreadable |
not a readable PEM public key |
key_algorithm_unsupported |
not RSA |
key_too_small |
RSA below 2048 bits |
key_already_registered |
(409) that exact key is already registered; the detail names its kid |
key_limit_reached |
(409) the application is at its ceiling of active keys |
key_not_found |
(404) no key with that id belongs to this application |
idempotency_key_required |
(400) creating requests need an Idempotency-Key UUID header |
idempotency_key_reused |
(409) that key was already used with a different body |
idempotency_request_in_flight |
(409) the first request with that key has not finished |
key_identifier_conflict |
(409) an identifier could not be generated for the key; retry |
7. Tenancy
A token names one tenant and a set of scopes, and reaches nothing outside either.
This is enforced twice: the API refuses and says why, and the store underneath returns nothing belonging to another tenant, so a fault in the layer above cannot widen the boundary. Your key is looked up inside your tenant's scope, which means a token cannot reach a key outside the tenant it names.
A resource belonging to another tenant answers 404, not 403. The answer
tells you nothing about what exists elsewhere.
8. Resolution of last_used_at
last_used_at on a key has one-minute resolution. It is not written on every
request, so a key used seconds ago may still show the previous minute. Use it
to answer "is anything still signing with this key", which is what it is
precise enough for.