Authentication
Service client authentication with the OAuth 2.0 client_credentials grant. Your server exchanges its client credentials for a short-lived Bearer token.
Which caller are you?
- Service client (a server-side integration): this page. Sends
Authorization: Bearer <token>. - Device (a scanner, tablet or till app): use the OAuth device flow with DPoP, described in Device authentication. Sends
Authorization: DPoP <token>plus aDPoPproof header. A device token sent as Bearer is rejected with401.
Each endpoint page lists which of the two it accepts and the scope it needs.
How it works
- Register a service client in the admin dashboard and receive a
client_idandclient_secret - Get an access token — POST your
client_idandclient_secretto/api/oauth/token, then call any API endpoint using the token as a Bearer token
Register a service client
A tenant admin registers service clients through the admin dashboard. Navigate to Integrations → API Clients and click Create Client.
During registration you provide:
- Name — a human-readable label for this integration
- Description — optional description of the integration
- Permissions — the scopes this client may request (see Available permissions)
The system generates a client_id (format: svc_<24 hex chars>) and a client_secret (format: scs_<48 hex chars>). Save the client secret immediately — it is a single-line string shown only once and cannot be retrieved again. The server stores only a SHA-256 hash of the secret.
Step 1 — Get an access token
POST your client_id and client_secret to the token endpoint to receive a short-lived access token. Use application/x-www-form-urlencoded encoding. A JSON body is rejected with 400 invalid_request.
scope is optional. Leave it out to get every scope the client is registered for, or pass a space-separated subset to get a narrower token.
Store your client secret as an environment variable — it is a single-line string that is natively .env-friendly:
CLIENT_ID='svc_3f9a1c7e5b2d8046a1e9c3b7' CLIENT_SECRET='scs_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6'
Avoid committing secrets to source control.
curl -s -X POST https://abyrgverslun.is/api/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials\
&client_id=svc_3f9a1c7e5b2d8046a1e9c3b7\
&client_secret=scs_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6\
&scope=devices:read transactions:read" | jq .const CLIENT_ID = process.env.CLIENT_ID!;
const CLIENT_SECRET = process.env.CLIENT_SECRET!;
const TOKEN_ENDPOINT = "https://abyrgverslun.is/api/oauth/token";
async function getAccessToken(): Promise<string> {
const res = await fetch(TOKEN_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: "devices:read transactions:read",
}),
});
if (!res.ok) {
const error = await res.json();
throw new Error(`Token exchange failed: ${error.error}`);
}
const { access_token } = await res.json();
return access_token;
}
// Usage
const token = await getAccessToken();
const devices = await fetch("https://abyrgverslun.is/api/v1/devices", {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());import os
import requests
CLIENT_ID = os.environ["CLIENT_ID"]
CLIENT_SECRET = os.environ["CLIENT_SECRET"]
TOKEN_ENDPOINT = "https://abyrgverslun.is/api/oauth/token"
def get_access_token() -> str:
res = requests.post(
TOKEN_ENDPOINT,
data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": "devices:read transactions:read",
},
)
res.raise_for_status()
return res.json()["access_token"]
# Usage
token = get_access_token()
devices = requests.get(
"https://abyrgverslun.is/api/v1/devices",
headers={"Authorization": f"Bearer {token}"},
).json()var clientId = Environment.GetEnvironmentVariable("CLIENT_ID")!;
var clientSecret = Environment.GetEnvironmentVariable("CLIENT_SECRET")!;
const string TokenEndpoint = "https://abyrgverslun.is/api/oauth/token";
async Task<string> GetAccessTokenAsync()
{
using var http = new HttpClient();
var res = await http.PostAsync(TokenEndpoint, new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = clientId,
["client_secret"] = clientSecret,
["scope"] = "devices:read transactions:read",
}));
res.EnsureSuccessStatusCode();
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
return json.GetProperty("access_token").GetString()!;
}
// Usage
var token = await GetAccessTokenAsync();
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var devices = await http.GetFromJsonAsync<JsonElement>(
"https://abyrgverslun.is/api/v1/devices");Response
/api/oauth/tokenExchange client credentials for an access token.
Headers
| Name | Type | Required | Description |
|---|---|---|---|
| Content-Type | string | Required | application/x-www-form-urlencoded |
Content-TypestringRequiredapplication/x-www-form-urlencoded
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
| grant_type | string | Required | Must be client_credentials |
| client_id | string | Required | Your service client ID (svc_…) |
| client_secret | string | Required | Your client secret (scs_…) |
| scope | string | Optional | Space-separated scopes to request; must be a subset of the client's registered scopes. Omit it to get all of them. |
grant_typestringRequiredMust be client_credentials
client_idstringRequiredYour service client ID (svc_…)
client_secretstringRequiredYour client secret (scs_…)
scopestringOptionalSpace-separated scopes to request; must be a subset of the client's registered scopes. Omit it to get all of them.
Response
{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "devices:read transactions:read"
}Token errors
The token endpoint answers errors in the OAuth format (RFC 6749), not Problem Details. See Errors for all formats.
{
"error": "invalid_scope",
"error_description": "Requested scopes exceed registered: employees:write"
}| Status | error | When |
|---|---|---|
| 400 | invalid_request | grant_type, client_id or client_secret is missing, or the body is not application/x-www-form-urlencoded (a JSON body reads as a missing grant_type) |
| 400 | unsupported_grant_type | grant_type is not client_credentials, refresh_token or urn:ietf:params:oauth:grant-type:device_code |
| 400 | invalid_scope | scope asks for a scope the client is not registered for; error_description lists them |
| 401 | invalid_client | unknown client_id, wrong secret, or the client is disabled or revoked. Sent with WWW-Authenticate and no error_description |
| 401 | unauthorized_client | the client's tenant is not active |
| 429 | slow_down | more than 30 token requests in a minute for this client_id. Wait for the Retry-After header (seconds) |
| 500 | server_error | a problem on our side; retry with backoff |
The 30-per-minute limit counts every request for a client_id, including failed ones. Cache your token and reuse it until it expires instead of requesting one per API call.
Step 2 — Use the access token
Include the token in the Authorization header of every API request. Tokens expire in 5 minutes (expires_in: 300). No refresh tokens are issued to service clients: when the token expires, request a new one with your client credentials.
The example below runs a pass verification. For the full request and response, see Verify Pass.
/api/v1/verificationsExample authenticated request: a pass verification. Needs the transactions:write scope. Both outcomes return HTTP 200 with result pass or fail.
Headers
| Name | Type | Required | Description |
|---|---|---|---|
| Authorization | string | Required | Bearer <access_token> |
| Content-Type | string | Required | application/json |
AuthorizationstringRequiredBearer <access_token>
Content-TypestringRequiredapplication/json
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
| method | string | Required | "pass" |
| passData | string | Required | The Pass Token read from the customer's wallet pass: 53 characters, the digit 2 followed by 52 Crockford Base32 characters |
| requiredAge | integer | Required | Minimum age to check, 0-150 (for example, 18) |
methodstringRequired"pass"
passDatastringRequiredThe Pass Token read from the customer's wallet pass: 53 characters, the digit 2 followed by 52 Crockford Base32 characters
requiredAgeintegerRequiredMinimum age to check, 0-150 (for example, 18)
Examples
cURL
ACCESS_TOKEN="eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9..."
curl -s -X POST https://abyrgverslun.is/api/v1/verifications \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"method": "pass",
"passData": "2QWPSZMFXGAGCH1NAJB0RMH298XB106N5E2D7WTW5M2H4XEHYZZ4H",
"requiredAge": 18
}' | jq .Response
{
"result": "pass",
"transactionId": "k57a3m9qz4x8c1n2v6b0d5f7gh1j2k3l"
}Available permissions
Request only the scopes your integration needs. A token without the scope an endpoint needs gets 403.
- A
:writescope covers create, update and delete, and the record a create or update returns. Listing or fetching records needs the:readscope. - Verifications (
POST /api/v1/verificationsand pollingGET /api/v1/verifications/{id}) needtransactions:write. Reading the transaction history needstransactions:read.
| Permission | Description |
|---|---|
| devices:read | List and read device details |
| devices:write | Create, update and delete devices |
| locations:read | List and read locations |
| locations:write | Create, update and delete locations |
| locations:delete | Delete locations, without create or update |
| employees:read | List and read employees, look up an employee by access code |
| employees:write | Create, update and delete employees |
| employees:delete | Delete employees, without create or update |
| transactions:read | List and read transaction records |
| transactions:write | Run verifications (pass, manual, eID) and poll their result |
Secret rotation
Rotate secrets without downtime using the Rotate Secret action on the client detail page in the dashboard. A new secret is generated automatically.
The old secret transitions to retiring status for a 24-hour grace window, during which both the old and new secrets are accepted. After the grace window the old secret is no longer valid. Update your CLIENT_SECRET environment variable during the grace window to avoid interruption.
Revoking tokens
An admin can invalidate all active tokens for a client with the Revoke All Tokens action in the dashboard. Every token issued before that moment is then rejected with 401. Disabling or revoking the client has the same effect on its tokens.
This is not strictly instant: each API server caches a client's status for about 5 seconds, so a revoked token can still work for a few seconds.