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.

FieldRequiredNotes
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:

  1. 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_scope naming the offending scopes; asking for less is supported and encouraged.
  2. What you request. Omitting scope gives you your full registration. Naming scopes narrows the token to those.
  3. 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 scopeWhy
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.

SituationStatusBody
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.

Built with