# Service Accounts

Service accounts are machine-to-machine clients that access the Capsule API for your tenant. Use them to integrate scripts, data pipelines, SIEM enrichment jobs, and other automation that needs Capsule data without a human signing in.

> **Note:** Service accounts are in preview - behavior and APIs may change.


## How access works

- A service account is an **OAuth 2.0 client**: a client ID and a client secret, scoped to a single tenant.
- Credentials are exchanged for a **short-lived access token**; the token, not the secret, is sent on API calls.
- Access is **read-only**. Every service account holds the same fixed permission set - there are no roles, scopes, or per-account narrowing.
- Session conversation content is always **redacted** for service accounts. See [What a service account can access](#what-a-service-account-can-access).


To connect an AI client such as Claude Code or Cursor to Capsule rather than calling the API yourself, see the [MCP Server](/guides/mcp-server) guide - it uses a service account for authentication and handles the token exchange for you.

## Creating a service account

1. Go to **Settings → Service Accounts**.
2. Click **Create service account** and give it a name (up to 100 characters), for example `CI pipeline`.
3. Capsule shows the **Client ID** and **Client secret**.
4. Copy the client secret now and store it in a secrets manager - it is shown only once and cannot be retrieved later. The client ID stays visible in the list and can be copied at any time.


Creating, rotating, and deleting service accounts requires the `service_accounts:manage` permission; viewing them requires `service_accounts:read`. By default only the **Owner** role holds these. To delegate management, grant them to a [custom role](/guides/user-roles#custom-roles).

## Authenticating

Authentication is the standard OAuth 2.0 **client credentials** flow: exchange the client ID and secret for an access token, then send the token as a bearer on API calls.

### Step 1: Exchange credentials for a token

```
POST https://{capsule-host}/v1/auth/token
```

The request body must be **form-encoded** (`application/x-www-form-urlencoded`):

| Parameter | Value |
|  --- | --- |
| `grant_type` | `client_credentials` (literal) |
| `client_id` | The service account's client ID |
| `client_secret` | The service account's secret |


```bash
curl -X POST "https://{capsule-host}/v1/auth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=$CAPSULE_CLIENT_ID" \
  -d "client_secret=$CAPSULE_CLIENT_SECRET"
```

**Success response:**

```json
{
  "access_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 900
}
```

**Error responses:**

| Status | `error` | Meaning |
|  --- | --- | --- |
| `400` | `invalid_request` | Malformed body - send all three parameters, form-encoded |
| `400` | `unsupported_grant_type` | `grant_type` must be `client_credentials` |
| `401` | `invalid_client` | Unknown client ID, wrong secret, or a revoked secret or deleted account |
| `429` | - | Token requests are limited to 10 per minute |


### Step 2: Call the API with the token

Send the token in the `Authorization` header on every request:

```
Authorization: Bearer <access_token>
```

Tokens expire after **15 minutes**. Cache the token and reuse it until shortly before expiry, then exchange credentials again - do not perform a token exchange per API call, or you will hit the token endpoint's rate limit. When an API call returns `401`, exchange credentials for a fresh token and retry once.

## Making API calls

The Capsule API is GraphQL, served at a single endpoint:

```
POST https://{capsule-host}/graphql
```

| Header | Value |
|  --- | --- |
| `Authorization` | `Bearer <access_token>` |
| `Content-Type` | `application/json` |


The request body is a JSON object with a `query` string and an optional `variables` object. For example, to search the agent inventory and include each agent's findings:

```graphql
query Agents($filter: AgentFilter, $limit: Int, $offset: Int) {
  agents(filter: $filter, limit: $limit, offset: $offset) {
    items {
      id
      name
      description
      findings {
        id
        title
      }
    }
    total
  }
}
```

As a curl call:

```bash
curl -X POST "https://{capsule-host}/graphql" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "query Agents($filter: AgentFilter, $limit: Int, $offset: Int) { agents(filter: $filter, limit: $limit, offset: $offset) { items { id name description findings { id title } } total } }",
    "variables": { "filter": { "search": "support" }, "limit": 25, "offset": 0 }
  }'
```

**Response:**

```json
{
  "data": {
    "agents": {
      "items": [
        {
          "id": "0b6de5e8-7f2a-4c1d-9e3b-2a8c5d1f0e47",
          "name": "Customer Support Agent",
          "description": "Handles customer support requests",
          "findings": [{ "id": "8f2c31a0-4b6e-4d2f-a1c9-7e5b3d0f8a12", "title": "Public agent exposes an internal tool" }]
        }
      ],
      "total": 1
    }
  }
}
```

List queries take `limit` and `offset` arguments and return a `total` count for pagination; `limit` defaults to 100. GraphQL errors (an unknown field, a malformed query) come back as an `errors` array in a `200` response, while authentication failures return `401`.

### API reference

The full schema - every query, type, filter, and enum - is documented in the [GraphQL API reference](/apis/agent). It covers the agent inventory (`agents`, `agent`), tools, data sources, access channels, integrations, and the aggregation queries that power dashboards.

GraphQL introspection is disabled in production, so tooling that discovers the schema by introspecting the live API will not work - use the reference instead.

## What a service account can access

Service accounts hold a fixed, read-only permission set:

| Permission | Grants |
|  --- | --- |
| `sessions:read` | View agent session metadata: participants, timing, counts, and timelines. |
| `findings:read` | View resource findings. |
| `detections:read` | View detections. |


Because service accounts do not hold the session content permissions, private conversation content (prompts, responses, tool input and output) is replaced with a redaction placeholder in API responses. Identity fields such as user emails remain visible so results can still be attributed. See [Redaction behavior](/guides/user-roles#redaction-behavior) for how this works.

A service account is scoped to exactly one tenant and cannot manage other service accounts, users, policies, or integrations.

## Rotating and revoking credentials

A service account can hold up to **two active secrets** at once, so you can rotate credentials without downtime:

1. In **Settings → Service Accounts**, open the account's row and click **Add secret**. Copy the new secret - like the original, it is shown only once.
2. Update your client configuration to use the new secret and verify it works.
3. Expand **Secrets** on the account's row and click **Revoke** on the old secret.


Revoking a secret immediately stops **new token issuance** with that secret. Access tokens that were already issued stay valid until they expire (up to 15 minutes). To cut off an account's access immediately, **delete the service account** - this revokes all of its secrets and stops API access at once. Deletion cannot be undone.

Each secret's row shows when it was created and last used, which helps confirm a rotated-away secret is no longer in use before revoking it.

## Limits

| Limit | Value |
|  --- | --- |
| Service accounts per tenant | 10 |
| Active secrets per account | 2 |
| Name length | 100 characters |
| Access token lifetime | 15 minutes |
| Token endpoint rate limit | 10 requests per minute |
| API rate limit | 60 requests per minute per account |


The API rate limit is counted **per service account**, not per token or per source IP - exchanging credentials again does not grant extra budget. Give each connected client its own service account rather than sharing one, so a chatty client cannot starve the others.

## Troubleshooting

| Symptom | Cause | Resolution |
|  --- | --- | --- |
| `401 invalid_client` on token exchange | Wrong credentials, or the secret was revoked | Check the client ID and secret; add a new secret in **Settings → Service Accounts** |
| `415 Unsupported Media Type` | Token request sent as JSON | Send the token request form-encoded (`application/x-www-form-urlencoded`) |
| `401` on API calls | Access token expired, or the account was deleted | Exchange credentials for a fresh token; confirm the account still exists |
| `429` responses | Rate limit exceeded | Cache tokens instead of re-exchanging; use one service account per client |
| Conversation content shows as redacted | Expected - service accounts are metadata-only | Use a portal user with content permissions for investigations that need content |


## Security considerations

- Store client secrets in a secrets manager or environment variables - never hardcode them in source code.
- Rotate secrets periodically using the two-active-secrets flow above.
- Create one service account per client or system, so each can be rotated, rate-limited, and revoked independently.
- Delete service accounts that are no longer used. The **Last used** timestamp on each account and secret shows whether it is still active.
- Use HTTPS exclusively - all Capsule endpoints require TLS.