# Authentication and permissions

> Integration key, tokens, token refresh and the permissions model.

## Flow

```plaintext
integration secret key ──> POST /v1/auth/login ──> access_token + refresh_token
                                                              │
                              POST /v2/auth/refresh <─────────┘  when expired
```

![Overview: the secret key is exchanged for a token pair via POST /v1/auth/login, requests are sent with access\_token, when it expires the pair is refreshed via POST /v2/auth/refresh, and if the refresh fails a new sign-in with the secret key is performed](/_astro/01-overview.DMuaIw_n_Z206cPN.webp)

Of the methods in this documentation, only these two work without a token. All other requests are sent with the header:

```plaintext
Authorization: Bearer <access_token>
```

![Request with a token: GET with the Authorization Bearer access\_token header, a valid token gets 200, an expired one gets 401 Token is expired](/_astro/02-use-token.xdjUXBrA_176idQ.webp)

## Token lifetime

For sign-in with the secret key the lifetimes are set in code and do not depend on company settings:

| Token           | Lifetime    | In the login response                                                    |
| --------------- | ----------- | ------------------------------------------------------------------------ |
| `access_token`  | **15 days** | `expires_in: 1296000` (seconds)                                          |
| `refresh_token` | 30 days     | returned in the `refresh_token` field; it has no `expires_in` of its own |

> **After a refresh the lifetime may differ**
>
> A refresh (`POST /v2/auth/refresh`) issues a pair with lifetimes taken from the server configuration. Read `expires_in` from the response every time.

## Token refresh

```bash
curl -X POST 'https://api-admin.billz.ai/v2/auth/refresh' \
  -H 'Content-Type: application/json' \
  -H 'platform-id: 7d4a4c38-dd84-4902-b744-0488b80a4c01' \
  -d '{ "refresh_token": "..." }'
```

The response contains a **new pair** of tokens with the `expires_in` and `refresh_expires_in` fields. The old refresh token can no longer be used.

![Refreshing the token pair: POST /v2/auth/refresh with refresh\_token, the response contains a new access\_token and refresh\_token, the old pair is replaced](/_astro/04-refresh.D_WaiJyq_Z1T4GfQ.webp)

> **Reusing a refresh token revokes the whole session family**
>
> Rotation is atomic: one refresh token is exchanged exactly once. If an already spent token is presented, the server treats this as a compromise and revokes the **whole session family** that grew out of the original login — after that only a new `POST /v1/auth/login` helps. The response to such an attempt is `400`, `"error": "invalid_grant"`, `"message": "token reuse detected"`.
>
> Parallel refreshes with the same refresh token within **5 seconds** receive the same new pair. The pair is kept in shared storage and refreshed from a single place.

Refresh errors come with code `400` (RFC 6749 §5.2): `401` from a regular method means an expired access token, `400` from the refresh means an unusable refresh token. The `error` field carries the standard code: `invalid_request`, `invalid_grant`, `unauthorized_client`; an internal failure is `500` with `server_error`.

## Permissions

Key access is built from two independent levels.

**Level 1 — route.** The pair “HTTP method + route template” must be present in the **fixed list of routes**, which is the same for all companies. The full list: [what an integration key can do](/en/reference/permissions/).

**Level 2 — role permission.** The key has its own role. Its access switches are checked while an allowed request is executed and decide what the response contains and whether the operation goes through: [key role permissions](/en/reference/role-permissions/).

The route **template** (`/v2/transfer/:id/add`) and the HTTP verb are compared: `PUT` where `PATCH` is registered does not pass; ID values do not affect the check.

Permissions are cached for **20 minutes**; changing the role resets the cache.

## What the response codes mean

The error body is usually `{"code": ..., "message": ..., "error": ..., "data": null}`. The cause is distinguished by the `message` + `error` pair:

| Code  | `message` / `error`                                            | What happened                                                       |
| ----- | -------------------------------------------------------------- | ------------------------------------------------------------------- |
| `401` | `token error`                                                  | the token cannot be parsed: truncated, corrupted, foreign signature |
| `401` | `token expired` / `token has been expired`                     | expired — a refresh is required                                     |
| `401` | `unauthorized` / `refresh token used as access`                | a refresh token was put in `Authorization`                          |
| `401` | `unauthorized` / `session not found`, `session revoked`        | the session is closed or revoked — a new login is required          |
| `401` | `unauthorized` / `mismatch platform-id with token info`        | `platform-id` differs from the one the token was issued with        |
| `401` | `unauthorized` / `user is not active`, `user has been expired` | the key is disabled or has expired                                  |
| `403` | `forbidden` / `access denied`                                  | the route is not in the list available to the key                   |
| `403` | `blocked` / `company is blocked`                               | the whole company is blocked                                        |

These cases are covered in [403 and 404](/en/troubleshooting/403-and-404/).

Client procedure on `401`: refresh the pair while the refresh token is valid, otherwise sign in again with the secret key, then repeat the original request.

![Handling 401: if refresh\_token is valid, send POST /v2/auth/refresh, otherwise sign in again with the secret key, then repeat the original request](/_astro/05-401.CUC4gMSF_1RHh.webp)

> **`platform-id` is optional everywhere except the token refresh**

## Best practices

* Store the secret key in secure storage.
* Refresh `access_token` before it expires.
* Perform the refresh from a single place.
* Create a **separate key** for each integration.