Get started
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.
On this page
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 withcs_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).
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. |
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"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();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()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:
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.
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;
}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 _tokenusing 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. | |
alerts:write | Acknowledge and resolve alerts (always with the operator). | |
positions:read | Positions the privacy veil allows. | |
musters:read | Evacuations and headcount. | |
workers:read | Workers and watches. | |
webhooks:manage | Webhook destinations. |
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:
- Request a new secret through the same channel you used to request access.
- Deploy the new one to your services. In the meantime, those still using the old one keep working.
- Check that all tokens are now requested with the new one.
- 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.
Token errors
The token endpoint responds in the OAuth2 format (RFC 6749 §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.