OAuth 2.0
Every call to a Kesk API carries a bearer token. This page is the reference for the endpoint that issues one, the endpoint that revokes it, and the scope model that decides what it can reach.
The same two endpoints also answer on enterprise.apis.kesk.app. Use the host
of the API you are calling: the token is minted against your client's
registration, and the claims it carries — which business, which enterprise —
come from that registration rather than from the host.
The authorization server publishes its metadata at
accounts.kesk.app/.well-known/oauth-authorization-server,
and the token_endpoint there names a different host,
oauth2.apis.kesk.app. That is the authorization server's own endpoint, and it
takes an application/x-www-form-urlencoded body — posting JSON to it returns
415. The endpoint documented on this page takes JSON. They are not
interchangeable; a request body copied from one is rejected by the other.
For machine credentials, use the URL above. Observed 2026-08-16.
Getting a token
client_credentials is the only grant this endpoint supports. Authenticate with
HTTP Basic — client ID as username, secret as password — and send the grant type
as JSON.
| Field | Required | Notes |
|---|---|---|
grant_type | yes | client_credentials |
scope | no | Space-delimited. Defaults to your client's full registered scope |
Credentials go in the Authorization header, not the body. There is no
client_id or client_secret field on this request; sending them in the JSON
leaves the request unauthenticated and returns 401.
A successful response:
No refresh_token is issued for this grant — the key is absent from the
response, not null. Re-request a token when the current one nears expiry.
Read scope and expires_in from the response rather than assuming either. The
granted scope can be narrower than what you asked for, and the lifetime is set
server-side. Never hardcode a lifetime.
Using the token
Cache the token for its expires_in and refresh on a margin. The token endpoint
does not emit RateLimit headers, but requesting a fresh token per call is
wasteful and slow.
Treat the access token as opaque. It is a JWT, but it is signed with a symmetric
key: the RSA keys published at accounts.kesk.app/oauth2/jwks.json are for OIDC
id_tokens and cannot verify an access token. It also carries no sub and
no iss claim, so a standard JWT library will not find a subject where it
expects one. If you need to inspect a token, call the introspection endpoint at
accounts.kesk.app/oauth2/introspect rather than decoding it yourself.
Scopes
Scopes are a space-delimited string in the request and in the response. The
taxonomy is <surface>:<resource>:<action>, where surface is users,
vendors, enterprise or units, and action is read or write.
Three things decide whether a call succeeds:
- Your client's registration. A scope must be on your client's registered
grant before a token can carry it. Asking for more returns
400 invalid_scopenaming the offending scopes; asking for less is supported and encouraged. - What you request. Omitting
scopegives you your full registration. Naming scopes narrows the token to those. - What the endpoint requires. Each operation names the scope that gates it.
Matching
A granted scope satisfies a required one by exact match, or by a
<surface>:* wildcard matched on the : boundary. vendors:* grants
vendors:orders:read; it does not grant users:orders:read. There is no *
super-wildcard — a bare * in a granted set matches nothing at all.
Coarse grants
users, vendors and enterprise are coarse grants. Each expands at issuance
into the granular scopes of its surface, minus eight the registry withholds by
name:
| Withheld scope | Why |
|---|---|
users:payments:read | Payment history |
users:profile:write | Writes to a person's own profile |
vendors:units:read | Enumerates a partner's autonomous hardware |
vendors:units:write | Issues machine credentials for that hardware |
vendors:promotions:write | Mints coupons that discount the partner's own revenue |
enterprise:users:write | Invites, suspends and removes members |
enterprise:scim:read | Enumerates a tenant's whole directory |
enterprise:scim:write | Creates, deactivates and deletes any member |
Those eight must be requested by name. The surface wildcard does cover them —
enterprise:* grants enterprise:scim:read, while a bare enterprise does
not. Ask for a wildcard deliberately, not as shorthand for the coarse grant.
The scope echoed in the token response is the form you requested,
canonicalised but not expanded — ask for vendors and you get vendors back,
not the list it expanded into. It remains the record of what you were granted;
it is simply recorded at the granularity you asked at.
The complete registry is published as scopes_supported at
accounts.kesk.app/.well-known/oauth-authorization-server.
Note the host: that is the authorization server, not an API host, so the path
does not resolve on api.kesk.app.
units:* scopes are absent from that document on purpose — it advertises what a
developer application may request, and no application can ask for one. They
are carried only by credentials minted for a single piece of hardware.
Errors
Three different response shapes come out of this one endpoint. Observed 2026-08-16.
| Situation | Status | Body |
|---|---|---|
Missing Authorization header | 401 | {"title":"Unauthorized","message":"Authentication required.","status":401,"type":"unauthorized"} |
Wrong client ID or secret | 401 | {"error":"invalid_client","error_description":"Client authentication failed."} |
Unsupported grant_type | 400 | {"error":"unsupported_grant_type","error_description":"Only 'client_credentials' grant type is supported."} |
Unknown scope | 400 | {"error":"invalid_scope","error_description":"Unknown scope: ..."} |
Scope not on your registration | 400 | {"error":"invalid_scope","error_description":"Scope exceeds the client's registered grant: ..."} |
Form-encoded body | 400 | {"error":"Bad Request","message":"invalid content type ..., expect: application/json"} |
Unknown scopes are checked before entitlement, so a typo fails as a typo rather than as "exceeds your grant" — which would send you looking in the wrong place.
Note that the missing-header case is the only one that does not use an error
key. Code that reads body.error will find undefined there.
Revoking a token
The token endpoint takes JSON; the revoke endpoint takes
application/x-www-form-urlencoded. Sending JSON here returns 415 with a
plain-text body — parsing it as JSON will throw.
token_type_hint is optional and ignored. access_token is the only value the
endpoint will parse, so send that or omit the field; any other string is a
400. Revocation applies to access tokens — passing a refresh token returns
200 and revokes nothing.
Revocation kills the entire token family, not just the string you passed, which is what you want when responding to a leak. It takes effect immediately: the APIs re-check liveness on every request rather than trusting the token's own expiry.
Per RFC 7009 the endpoint returns 200 even for a token it does not recognise,
so a 200 means "not valid any more" rather than "existed and was revoked".
Treat it as idempotent and safe to retry.
To rotate a secret: issue and deploy the new credentials, drain in-flight work, then revoke the old token. Revoking first breaks every worker still holding it.