---
title: Service-to-service integration
path: tutorials/service-to-service
status: published
---

# Service-to-service integration

A complete walkthrough for the case where a backend service needs to call ScaiKey's admin API on its own behalf — no user involvement. End state: your service has a token-acquiring loop, calls a protected endpoint successfully, and handles token expiry cleanly.

If you're integrating an interactive user-facing app instead, this isn't the right tutorial — use `authorization_code` flow.

## Decide on scope upfront

A service-to-service integration in ScaiKey is one `SERVICE` (or `WEB`) application using the `client_credentials` grant. Before you register, decide:

1. **Which tier?** If your service operates across all tenants (e.g. a usage-aggregation job), it's `GLOBAL`. If it only talks to one tenant's resources, it's `TENANT`.
2. **What does it need to do?** This determines `allowed_scopes`. Read-only services need `admin:read`. Services that create/update/delete need `admin:write`. Narrower scopes like `users:read`, `groups:read` exist if you want to limit blast radius.

You can change `allowed_scopes` later, but you cannot change the application's `scope` (GLOBAL/PARTNER/TENANT) — that's set at registration.

## 1. Register the application

In the admin UI (`$SCAIKEY/admin/applications`), create a new application. For a `GLOBAL` service:

| Field | Value |
|---|---|
| Name | `MyService` (use something a human can recognize in audit logs) |
| Type | `SERVICE` |
| Scope | `GLOBAL` |
| Allowed scopes | `openid`, `admin:read` (and `admin:write` if needed) |
| Token lifetime | 3600 (default 1 h) — bump only if you have a reason |

Save. The UI displays the `client_id` and a one-time `client_secret`. Copy both immediately into your service's configuration — the secret cannot be retrieved later. If it leaks, rotate by generating a new secret on the same application.

```bash
# Your service's config (typically env vars or a secret store)
export SCAIKEY_CLIENT_ID="<from the UI>"
export SCAIKEY_CLIENT_SECRET="<from the UI, shown once>"
export SCAIKEY_BASE="https://scaikey.scailabs.ai"
```

## 2. Acquire a token

```python
import os, time, httpx

class ScaiKeyClient:
    def __init__(self):
        self.base = os.environ["SCAIKEY_BASE"]
        self.client_id = os.environ["SCAIKEY_CLIENT_ID"]
        self.client_secret = os.environ["SCAIKEY_CLIENT_SECRET"]
        self._token = None
        self._token_expiry = 0

    def _fetch_token(self):
        r = httpx.post(
            f"{self.base}/api/v1/platform/oauth/token",
            auth=(self.client_id, self.client_secret),
            data={"grant_type": "client_credentials"},
            timeout=10,
        )
        r.raise_for_status()
        body = r.json()
        self._token = body["access_token"]
        # Refresh 30 s before actual expiry to avoid races
        self._token_expiry = time.time() + body["expires_in"] - 30

    def token(self):
        if not self._token or time.time() >= self._token_expiry:
            self._fetch_token()
        return self._token
```

The same shape works in any language — `POST` with Basic auth, body `grant_type=client_credentials`, cache the response by expiry. Don't fetch a fresh token on every call; ScaiKey will give you the same token-shape every time and the issuance load is wasted.

For tenant-scoped apps, swap the URL to `/api/v1/auth/tenants/{slug}/oauth/token`.

## 3. Call a protected endpoint

```python
def list_tenants(client):
    r = httpx.get(
        f"{client.base}/api/v1/admin/tenants",
        headers={"Authorization": f"Bearer {client.token()}"},
        timeout=10,
    )
    r.raise_for_status()
    return r.json()
```

If you get `403 Platform token requires admin:read or admin:write scope`, your application's `allowed_scopes` doesn't include the admin scope — see [Troubleshooting → Platform token 403](/docs/scaikey/troubleshooting/platform-token-403).

## 4. Handle token expiry mid-request

A token can expire while a request is in flight (in practice this is rare with a 30-second refresh window, but it happens). The clean handler:

```python
def call_with_retry(client, method, path, **kwargs):
    headers = {"Authorization": f"Bearer {client.token()}"}
    r = httpx.request(method, f"{client.base}{path}", headers=headers, **kwargs)
    if r.status_code == 401:
        # Token rejected — invalidate cache and retry once
        client._token = None
        headers["Authorization"] = f"Bearer {client.token()}"
        r = httpx.request(method, f"{client.base}{path}", headers=headers, **kwargs)
    r.raise_for_status()
    return r
```

Don't retry indefinitely; one retry is enough to handle the race. Anything else is a real auth failure.

## 5. Logging and auditing

Every admin API call generates an audit log entry on ScaiKey's side, recording your `client_id` as the actor. The action shows up under the user's audit view at `$SCAIKEY/admin/audit`. Your service's `client_id` should therefore be:

- **Specific** — one application per integration, not a single shared "service-account" identity reused everywhere. When something goes wrong you want to know *which* service called.
- **Named recognizably** — the audit log shows the application's `name`, so name it after your service, not after a person.

## What you should *not* do

- **Don't share a client_secret between environments.** Register a separate app for staging, dev, prod — different `client_id`/`client_secret` pairs, so you can revoke one without affecting the others.
- **Don't put `admin:write` on services that only read.** Least privilege — if the service ever gets compromised, the blast radius is smaller.
- **Don't ship the `client_secret` to a browser or mobile app.** `client_credentials` is for confidential clients only. If your code path ever runs on a user's device, use `authorization_code` with PKCE instead.
- **Don't issue tokens from your service to *its own* clients.** ScaiKey is the OAuth provider; your service is a client. If your service is itself an authentication boundary for further downstream callers, you want Token Exchange — see [Concepts → OAuth and OIDC](/docs/scaikey/concepts/oauth-and-oidc#token-exchange).
