# Authentication and scopes

> OAuth2 client_credentials: how to request a token, which scopes exist, how to rotate the client_secret without downtime and which errors the token endpoint returns.

Page: https://safeonuba.com/en/developers/authentication · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change.

The API uses OAuth2 with the `client_credentials` flow: your server exchanges its credentials for a one-hour token and sends that token on every call.

## Credentials

Each customer is registered by hand, with a reason that is recorded. When you are registered you receive:

- a `client_id`, which identifies your company;
- a `client_secret`, which starts with `cs_` and **is shown only once**.

A client can have **two active secrets at the same time**. That is what lets you rotate it without cutting the integration (see [Rotating the client_secret](https://safeonuba.com/en/developers/authentication#rotating-the-client-secret)).

The `client_secret` lives on your server, in a secrets manager or an environment variable. Never in a browser, a desktop application or a repository.

## Requesting a token

`POST /v1/oauth/token`, with the body as `application/x-www-form-urlencoded`:

| Field | Required | Value |
| --- | --- | --- |
| `grant_type` | Yes | `client_credentials` |
| `client_id` | Yes | Your client identifier. |
| `client_secret` | Yes | Your secret. |
| `scope` | No | Space-separated scopes, from among those your client has. Without it, the token carries all of your client’s scopes. |

```bash
curl -X POST https://sandbox.api.safeonuba.com/v1/oauth/token \
  -d grant_type=client_credentials \
  -d client_id="$SAFEONUBA_CLIENT_ID" \
  -d client_secret="$SAFEONUBA_CLIENT_SECRET" \
  -d scope="alerts:read musters:read"
```

```javascript
const res = await fetch('https://sandbox.api.safeonuba.com/v1/oauth/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: process.env.SAFEONUBA_CLIENT_ID,
    client_secret: process.env.SAFEONUBA_CLIENT_SECRET,
    scope: 'alerts:read musters:read',
  }),
});
const token = await res.json();
```

```python
import os
import requests

res = requests.post(
    "https://sandbox.api.safeonuba.com/v1/oauth/token",
    data={
        "grant_type": "client_credentials",
        "client_id": os.environ["SAFEONUBA_CLIENT_ID"],
        "client_secret": os.environ["SAFEONUBA_CLIENT_SECRET"],
        "scope": "alerts:read musters:read",
    },
    timeout=10,
)
res.raise_for_status()
token = res.json()
```

```csharp
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient();
var res = await http.PostAsync("https://sandbox.api.safeonuba.com/v1/oauth/token",
    new FormUrlEncodedContent(new Dictionary<string, string>
    {
        ["grant_type"] = "client_credentials",
        ["client_id"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_ID")!,
        ["client_secret"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_SECRET")!,
        ["scope"] = "alerts:read musters:read",
    }));
res.EnsureSuccessStatusCode();
var token = await res.Content.ReadFromJsonAsync<JsonElement>();
```

The response:

| Field | What it is |
| --- | --- |
| `access_token` | The token. It is opaque: don’t try to read it, just send it back. |
| `token_type` | Always `Bearer`. |
| `expires_in` | Seconds of validity: `3600`. |
| `scope` | The scopes the token carries, separated by spaces. |

The token is revoked instantly if the client is deregistered.

## Using the token

On every other call, in the `Authorization` header:

```http
GET /v1/alerts?status=unacknowledged HTTP/1.1
Host: sandbox.api.safeonuba.com
Authorization: Bearer <token>
```

## Renewing it

`client_credentials` has no refresh token: when one expires you request another with the same credentials. Request one, keep it in memory and renew it a little before it expires. Don’t request a token for every call: the token endpoint has its own limit per IP.

```javascript
let token = null;
let expiresAt = 0;

export async function safeOnubaToken() {
  // Renewed a minute before it expires.
  if (token && Date.now() < expiresAt - 60_000) return token;
  const res = await fetch('https://sandbox.api.safeonuba.com/v1/oauth/token', {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: process.env.SAFEONUBA_CLIENT_ID,
      client_secret: process.env.SAFEONUBA_CLIENT_SECRET,
    }),
  });
  if (!res.ok) throw new Error(`token: ${res.status} ${await res.text()}`);
  const data = await res.json();
  token = data.access_token;
  expiresAt = Date.now() + data.expires_in * 1000;
  return token;
}
```

```python
import os
import time
import requests

_token = None
_expires_at = 0.0

def safeonuba_token() -> str:
    global _token, _expires_at
    # Renewed a minute before it expires.
    if _token and time.time() < _expires_at - 60:
        return _token
    res = requests.post(
        "https://sandbox.api.safeonuba.com/v1/oauth/token",
        data={
            "grant_type": "client_credentials",
            "client_id": os.environ["SAFEONUBA_CLIENT_ID"],
            "client_secret": os.environ["SAFEONUBA_CLIENT_SECRET"],
        },
        timeout=10,
    )
    res.raise_for_status()
    data = res.json()
    _token = data["access_token"]
    _expires_at = time.time() + data["expires_in"]
    return _token
```

```csharp
using System.Net.Http.Json;
using System.Text.Json;

public sealed class SafeOnubaToken(HttpClient http)
{
    private readonly SemaphoreSlim _lock = new(1, 1);
    private string? _token;
    private DateTimeOffset _expiresAt;

    public async Task<string> FetchAsync()
    {
        await _lock.WaitAsync();
        try
        {
            // Renewed a minute before it expires.
            if (_token is not null && DateTimeOffset.UtcNow < _expiresAt.AddMinutes(-1)) return _token;
            var res = await http.PostAsync("https://sandbox.api.safeonuba.com/v1/oauth/token",
                new FormUrlEncodedContent(new Dictionary<string, string>
                {
                    ["grant_type"] = "client_credentials",
                    ["client_id"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_ID")!,
                    ["client_secret"] = Environment.GetEnvironmentVariable("SAFEONUBA_CLIENT_SECRET")!,
                }));
            res.EnsureSuccessStatusCode();
            var data = await res.Content.ReadFromJsonAsync<JsonElement>();
            _token = data.GetProperty("access_token").GetString()!;
            _expiresAt = DateTimeOffset.UtcNow.AddSeconds(data.GetProperty("expires_in").GetDouble());
            return _token;
        }
        finally
        {
            _lock.Release();
        }
    }
}
```

If a call returns `401` with a token you thought was valid (because it has been revoked, for example), discard the one you have and request another, once. If it fails again, don’t retry in a loop: check the credentials.

## Scopes

Each token carries the scopes your client has been granted, or the subset you ask for in `scope`. Ask only for those each service uses: the webhook receiver, for example, needs none, because it doesn’t call the API.

| Scope | What for | Operations |
| --- | --- | --- |
| `alerts:read` | Read alerts and zones. | `GET /v1/sites/{id}/zones`, `GET /v1/alerts`, `GET /v1/alerts/{id}` |
| `alerts:write` | Acknowledge and resolve alerts (always with the operator). | `PUT /v1/alerts/{id}` |
| `positions:read` | Positions the privacy veil allows. | `GET /v1/positions/latest` |
| `musters:read` | Evacuations and headcount. | `GET /v1/musters`, `GET /v1/musters/{id}`, `GET /v1/musters/{id}/missing` |
| `workers:read` | Workers and watches. | `GET /v1/workers`, `GET /v1/workers/{id}`, `GET /v1/devices` |
| `webhooks:manage` | Webhook destinations. | `GET /v1/webhooks`, `POST /v1/webhooks`, `DELETE /v1/webhooks/{id}`, `POST /v1/webhooks/{id}/rotate-secret`, `POST /v1/webhooks/{id}/test` |

`GET /v1/sites`, `GET /v1/events` and `POST /v1/sandbox/scenarios` work with any valid token.

`alerts:write` always acts on behalf of a person: the body of `PUT /v1/alerts/{id}` requires the `actor`, the operator in your control room who acknowledges or closes the alert.

## Rotating the client_secret

Because a client can have two active secrets, rotation cuts nothing:

1. Request a new secret through the same channel you used to request access.
2. Deploy the new one to your services. In the meantime, those still using the old one keep working.
3. Check that all tokens are now requested with the new one.
4. Ask for the old one to be withdrawn.

The webhook signing secret is a different thing and is rotated through the API: see [Verifying a webhook signature](https://safeonuba.com/en/developers/webhooks/verify-signature#rotating-the-secret).

## Token errors

The token endpoint responds in the OAuth2 format ([RFC 6749 §5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2)), not in `problem+json`: an `error` field and, sometimes, `error_description`. It responds `401` if the client or the secret are wrong, and `400` for everything else.

| error | What it means |
| --- | --- |
| `invalid_request` | A required field is missing or the request is malformed. |
| `invalid_client` | The client_id or the client_secret is wrong, or the client has been deregistered. |
| `unauthorized_client` | The client is not authorised to use this grant type. |
| `unsupported_grant_type` | grant_type is not client_credentials. |
| `invalid_scope` | A scope was requested that does not exist or that the client does not have. |

On the rest of the API, a token that is missing, has expired or has been revoked gives `401`, and a token without the scope the path needs gives `403`. See [Errors](https://safeonuba.com/en/developers/errors).
