> ## Documentation Index
> Fetch the complete documentation index at: https://docs.katla.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Service accounts

> Machine-to-machine API keys for connecting Katla to your own platform, with scoped, revocable, team-bound credentials.

A service account is a Katla team member that is not a person. It gets an API key instead of
a login, so your platform — a CI pipeline, a provisioning job, an internal dashboard — can
call the Katla API without a human signing in and without anyone sharing their own
credentials.

<Note>
  Service accounts are available on the **Business** and **Custom** plans. On other plans the
  endpoints below return `403 SERVICE_ACCOUNTS_REQUIRE_PLAN`.
</Note>

## How it differs from the MCP server

The [MCP server](/mcp) acts as *you*: it runs an OAuth flow in a browser, and everything it
does is attributed to your user account. That is right for an AI assistant and wrong for a
server, which has no browser and should not be borrowing a person's identity.

A service account is its own identity. It is bound to one team, carries its own role and
scopes, and keeps working when the person who created it leaves.

## Create one

From **Team settings → API keys** in the dashboard, or through the API as a team admin:

```bash theme={null}
curl -X POST https://api.katla.app/teams/$TEAM_ID/service-accounts \
  -H "Authorization: Bearer $YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Deploy pipeline",
    "role": "member",
    "scopes": ["katla:read", "katla:write"],
    "expiresInDays": 365
  }'
```

```json theme={null}
{
  "serviceAccount": { "id": "…", "name": "Deploy pipeline", "role": "member", "…": "…" },
  "key": { "id": "…", "maskedKey": "katla_sk_••••••••a1b2", "…": "…" },
  "apiKey": "katla_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
```

<Warning>
  `apiKey` is returned once and never again — Katla stores only a SHA-256 hash of it. Put it
  straight into your secret store. If it is lost, issue a new key and revoke the old one.
</Warning>

## Use it

Send the key as a bearer token, exactly where a user access token would go:

```bash theme={null}
curl https://api.katla.app/teams/$TEAM_ID/sites \
  -H "Authorization: Bearer $KATLA_API_KEY"
```

Every endpoint that accepts a user token accepts a service-account key, with the exceptions
listed under [What a service account cannot do](#what-a-service-account-cannot-do).

## Roles and scopes

A service account has both, and both are enforced on every request.

| Role     | What it can reach                                                     |
| -------- | --------------------------------------------------------------------- |
| `member` | Read and operate the team's sites, scans, cookies and consent records |
| `admin`  | The above, plus team-level site management                            |

`owner` is not assignable. An owner can transfer or delete the team, which is not something a
credential sitting in a CI runner should be able to do.

| Scope         | What it allows                               |
| ------------- | -------------------------------------------- |
| `katla:read`  | `GET` requests only                          |
| `katla:write` | Everything the role allows, including writes |

A key without `katla:write` gets `403 INSUFFICIENT_SCOPE` on any non-`GET` request. Issue
read-only keys wherever the integration only reports — a dashboard, an alerting job — so a
leak cannot change anything.

## What a service account cannot do

The boundary is deliberate: a service account operates your **sites and compliance data**; it
cannot administer your **account**. It is refused, with `403 SERVICE_ACCOUNT_FORBIDDEN`, on:

* Billing — plans, payment methods, invoices, cancellation
* Team members — inviting, removing, or listing people
* Team lifecycle — creating, renaming, transferring, or deleting a team
* Google Tag Manager connection, which needs a human at a Google consent screen
* Service accounts themselves, including issuing further keys

That last one is the important one. A credential that can mint credentials turns a single
leaked key into permanent access that outlives revoking the key that leaked.

A key is also bound to one team for its whole life. Naming a different team's id in a URL
returns `403`, not that team's data.

## Rotate without downtime

An account can hold more than one live key, which is what makes rotation a deploy rather than
an outage:

```bash theme={null}
# 1. Issue a second key
curl -X POST https://api.katla.app/teams/$TEAM_ID/service-accounts/$ACCOUNT_ID/keys \
  -H "Authorization: Bearer $YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" -d '{"expiresInDays": 365}'

# 2. Deploy the new key everywhere it is used, then revoke the old one
curl -X DELETE https://api.katla.app/teams/$TEAM_ID/service-accounts/$ACCOUNT_ID/keys/$OLD_KEY_ID \
  -H "Authorization: Bearer $YOUR_ACCESS_TOKEN"
```

## Revoke

Revoking the account withdraws every key under it at once — use this if a machine is
decommissioned or you suspect a compromise:

```bash theme={null}
curl -X DELETE https://api.katla.app/teams/$TEAM_ID/service-accounts/$ACCOUNT_ID \
  -H "Authorization: Bearer $YOUR_ACCESS_TOKEN"
```

Revocation takes effect on the next request. Revoked accounts stay in the list rather than
disappearing, so the record of what existed and when it was withdrawn survives an incident.

## Keeping keys safe

* **Store them as secrets.** A `katla_sk_` key is a password. Never commit one, and never put
  one in frontend code — the API refuses cross-origin browser calls, so a key in a browser is
  exposed without even working.
* **Give each integration its own account.** Then revoking one does not break the others, and
  `lastUsedAt` tells you which integrations are actually live.
* **Prefer read-only.** Most integrations only report.
* **Set an expiry.** `expiresInDays` puts an upper bound on the damage from a key nobody
  remembers.

## Rate limits

Every credential gets its own budget, so one key cannot spend another's — or yours.

| Caller                | Limit                        |
| --------------------- | ---------------------------- |
| A service-account key | 600 requests/minute, per key |
| A signed-in user      | 600 requests/minute          |
| Unauthenticated       | 120 requests/minute, per IP  |
| Issuing a key         | 10/hour                      |

Over the limit you get `429` with `code: RATE_LIMIT_EXCEEDED`, a `retryAfterSeconds` field,
and a standard `Retry-After` header — back off on that rather than retrying immediately.
`X-RateLimit-Remaining` tells you how much budget is left before you get there.

Because the budget is per key, rotating a leaked key also gives the replacement a clean
slate rather than inheriting whatever the old one spent.

## Reference

All routes require a team `admin` and the Business or Custom plan.

| Method   | Path                                                            |
| -------- | --------------------------------------------------------------- |
| `GET`    | `/teams/:teamId/service-accounts`                               |
| `POST`   | `/teams/:teamId/service-accounts`                               |
| `PATCH`  | `/teams/:teamId/service-accounts/:serviceAccountId`             |
| `DELETE` | `/teams/:teamId/service-accounts/:serviceAccountId`             |
| `POST`   | `/teams/:teamId/service-accounts/:serviceAccountId/keys`        |
| `DELETE` | `/teams/:teamId/service-accounts/:serviceAccountId/keys/:keyId` |

### Errors

| Code                                | Meaning                                                      |
| ----------------------------------- | ------------------------------------------------------------ |
| `401`                               | The key is unknown, expired, or revoked                      |
| `403 SERVICE_ACCOUNTS_REQUIRE_PLAN` | The team is not on Business or Custom                        |
| `403 INSUFFICIENT_SCOPE`            | The key lacks `katla:write` for a write request              |
| `403 SERVICE_ACCOUNT_FORBIDDEN`     | An account-administration route, which machines cannot reach |
| `429 RATE_LIMIT_EXCEEDED`           | Over the budget for this key; wait for `Retry-After`         |
