Authentication
Every endpoint except GET /v1/health and the token endpoint itself requires a bearer token. Tokens
are obtained with the OAuth 2.0 client-credentials grant: there is no user in this flow and no
redirect — a server proves it is a tenant, and gets a token scoped to that tenant.
https://api.granska.cloud/v1/oauth/tokenExchanges a client id and secret for a short-lived bearer token.
Where the credentials come from
An organisation administrator creates an API key in the web application, under API-nycklar in the administration area. Creating one produces a pair:
client_id— an identifier. It is listed beside the key from then on and is not a secret.client_secret— shown once, at the moment of creation, and never again. Only a salted scrypt hash of it is stored, so a lost secret cannot be recovered; the key is revoked and a new one created in its place.
A key can be deactivated from the same screen. A deactivated key stops issuing tokens immediately, but a token already minted from it stays valid until it expires — deactivation is not revocation of tokens in flight.
Keys belong to one tenant. The token carries that tenant, and every call made with it reads and writes only that tenant's data. No parameter changes which tenant a call acts on.
Requesting a token
POST /v1/oauth/token with a JSON body. grant_type must be client_credentials; anything else is
rejected rather than ignored.
| Parameter | Description |
|---|---|
grant_type"client_credentials"·body·required | The only grant this gateway supports. |
client_idstring·body·required | The identifier issued with the API key. |
client_secretstring·body·required | The secret issued with the API key. Sent only to this endpoint, never on other calls. |
The response carries access_token, token_type and expires_in. Send the token on every
subsequent call as Authorization: Bearer <access_token>.
The client_secret is sent to this endpoint and to nothing else. If you find yourself putting it on
another call, something is wrong.
curl -X POST https://api.granska.cloud/v1/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "cli_9f3a2b7c",
"client_secret": "sk_live_2c8e41d0a7b6"
}'{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600
}Token lifetime
A token lives one hour. expires_in says so in seconds, and it is the value to trust rather than
a constant copied into your client.
There is no refresh token. When a token expires, request another one the same way. Requesting a token
spends no quota, so a client that fetches one per hour — or simply on every 401 — is behaving
correctly. Caching one across process restarts is an optimisation, not a requirement.
An expired token answers 401 TOKEN_EXPIRED, a distinct code from 401 UNAUTHORIZED precisely so a
client can tell "get a new token and retry" apart from "these credentials are wrong, and retrying
will not help".
Failures worth handling separately
401 UNAUTHORIZED on the token call means the client id is unknown, the secret does not match
it, or the key has been deactivated. The response does not distinguish the three, deliberately: a
caller probing for valid client ids learns nothing from the status.
503 SERVICE_UNAVAILABLE is retryable, and it is the one failure here that is. It means the
credential store was transiently unreachable, not that the credentials are bad. The response carries
a Retry-After header; back off for that many seconds and try again. Treating it as a permanent
authentication failure — clearing stored credentials, paging someone — is the wrong reaction to a
condition that clears itself.
500 INTERNAL_ERROR is worth one retry and no more. At any 4xx the request itself is the
problem and the answer will not change.
| Error | When |
|---|---|
400BAD_REQUEST | client_id or client_secret is missing. |
400UNSUPPORTED_GRANT_TYPE | grant_type is anything other than client_credentials. |
401UNAUTHORIZED | The client id is unknown, or the secret does not match it. |
500INTERNAL_ERROR | An unexpected server-side failure. Not probable from outside — reaching it means something is wrong. |
503SERVICE_UNAVAILABLE | The credential store is transiently unavailable; a Retry-After header says when to try again. Not probed: it needs an injected infrastructure failure. |