Skip to content

Authentication and delegation

Gungner uses OAuth2 client-credentials. A tenant grants a consumer — an application, a partner integration, or your own backend — a set of scopes. The consumer authenticates as itself (client_id / client_secret), not as an end user, and everything it does happens under the tenant’s delegation.

Two rules that shape everything else:

  • One tenant per consumer. A client_id/client_secret pair is valid for exactly one tenant. There’s no cross-tenant credential — if you work with multiple tenants, you hold a separate credential pair for each.
  • Every access is logged. Each call a consumer makes is recorded against that consumer and that tenant, so activity is auditable after the fact.

A grant is a set of scopes, and scopes gate what a consumer can reach — over REST and over MCP identically, since MCP tools map onto the same routes under the same token.

Scope Unlocks
identity:read Declared in the scope catalogue; not yet required by any live route.
events:read Reading the event log.
kyb:read Company data — profiles, officers, signatories, beneficial owners, engagements.
kyb:refresh The paid company refresh — POST /v1/companies/{orgnr}/refresh and the refresh_company tool — and nothing else. Since interface 1.6.0, kyb:read no longer opens it.
kyc:read Person data — profiles only.
monitoring:read Reading monitoring subscriptions — the read-only half of monitoring:subscribe.
monitoring:subscribe Creating, reading, and ending monitoring subscriptions.
screening:read Reading a screening check that already exists.
screening:run Running a new screening check — a write scope, since a check is an append-only, evidence-bearing result; it also lets you read back the check you just ran.
decisions:write Writing back risk decisions and flags. Declared in the scope catalogue; not yet required by any live route.

A consumer only ever sees what its scopes cover — a token minted with just kyb:read, for example, can’t reach person data, over REST or MCP.

Terminal window
curl -s -X POST https://api.gungner.ai/oauth/token \
-H 'content-type: application/json' \
-d '{
"grant_type": "client_credentials",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET"
}'
{ "access_token": "…", "token_type": "Bearer", "expires_in": 600 }

Send it as Authorization: Bearer <access_token> on every subsequent call. A small reusable TypeScript client:

interface GungnerClientOptions {
baseUrl: string;
clientId: string;
clientSecret: string;
}
function createGungnerClient({ baseUrl, clientId, clientSecret }: GungnerClientOptions) {
let token: { value: string; expiresAt: number } | undefined;
async function getToken(): Promise<string> {
if (token && token.expiresAt > Date.now()) return token.value;
const response = await fetch(`${baseUrl}/oauth/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret }),
});
const body = await response.json();
token = { value: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 - 30_000 };
return token.value;
}
return {
async get(path: string) {
const accessToken = await getToken();
return fetch(`${baseUrl}${path}`, { headers: { authorization: `Bearer ${accessToken}` } }).then((r) => r.json());
},
};
}

Tokens are short-lived. Re-mint before a token expires rather than holding one indefinitely — the client above does this by tracking expires_in and refreshing a little before the deadline, which is the pattern to follow in your own integration too.