---
title: Service users, API keys and OAuth clients
description: Create a non-human identity for a script, server or CI job, give it
  permissions, and manage its API keys and OAuth clients.
url: https://hexelstudio.com/docs/identity/service-users
---

A service user is an identity that belongs to your organization and is used by software, not a person. It has no password and can't sign in to the console. It authenticates with an **API key** or an **OAuth client**, and it can do only what the policies attached to it allow.

Use one service user per workload, so that you can limit, audit and revoke each one on its own.

## Before you start

You need an access token for your organization in `$ACCESS_TOKEN` and the organization ID in `$ORG_ID`. Each operation needs one of these permissions:

| Operation | Action |
| --- | --- |
| Create a service user | `directory:service_account:create` |
| List or read service users, their keys, clients and policies | `directory:service_account:read` |
| Update, disable or enable a service user | `directory:service_account:update` |
| Delete a service user | `directory:service_account:delete` |
| Create or revoke API keys; create, rotate or revoke OAuth clients | `directory:service_account:manage_keys` |
| Attach or detach policies | `iam:policy:attach` |

A service user ID from another organization answers `404`, the same as one that doesn't exist.

## Create a service user

```bash
curl -X POST https://api.hexelstudio.com/v1/service-users \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "X-Organization-Id: $ORG_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "ci-deployer",
    "display_name": "CI deployer",
    "description": "Deploys from the main branch"
  }'
```

<ParamField body="name" type="string" required>
  A name for the service user.
</ParamField>
<ParamField body="display_name" type="string" required>
  The name shown in the console.
</ParamField>
<ParamField body="description" type="string">
  Optional free text.
</ParamField>

The response is `201` with the service user:

```json
{
  "id": "su_0f8e4b2c9a7d4e1f8b3c6d5a4e3f2a1b",
  "name": "ci-deployer",
  "display_name": "CI deployer",
  "description": "Deploys from the main branch",
  "organization_id": "<org-id>",
  "is_active": true,
  "created_by_user_id": "<user-id>",
  "created_at": "2026-10-20T09:30:00Z",
  "updated_at": "2026-10-20T09:30:00Z"
}
```

The organization is always the one your token is for, and `created_by_user_id` is always you.

<Warning>
When the person who created a service user deletes their Hexel account, the service users they created are deleted too, and their keys stop working. Create long-lived service users from an account that will stay.
</Warning>

### List, read and update

```bash
# List the organization's service users (a JSON array)
curl https://api.hexelstudio.com/v1/service-users \
  -H "Authorization: Bearer $ACCESS_TOKEN"

# Read one
curl https://api.hexelstudio.com/v1/service-users/<service-user-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN"

# Change its display name or description
curl -X PATCH https://api.hexelstudio.com/v1/service-users/<service-user-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"display_name": "CI deployer (prod)"}'
```

`PATCH` changes only the fields you send (`name`, `display_name`, `description`, `is_active`) and returns the updated service user. `id`, `organization_id`, `created_by_user_id` and the timestamps can't be changed.

## Give it permissions

A new service user can do nothing. Attach [policies](https://hexelstudio.com/docs/identity/policies) to it:

```bash
curl -X POST https://api.hexelstudio.com/v1/service-users/<service-user-id>/policies \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"policy_id": "<policy-id>"}'
```

The response is `201` with the attachment:

```json
{
  "id": "<attachment-id>",
  "policy_id": "<policy-id>",
  "target_type": "SERVICE_USER",
  "target_id": "su_0f8e4b2c9a7d4e1f8b3c6d5a4e3f2a1b",
  "organization_id": "<org-id>",
  "attached_by_user_id": "<user-id>",
  "attached_at": "2026-10-20T09:31:00Z"
}
```

You can give a service user only what you hold yourself. If the policy allows an action that you aren't allowed across the organization, the request fails with `403` and lists the actions in `missing_actions`:

```json
{
  "error": "forbidden",
  "reason": "cannot grant actions the caller does not hold",
  "missing_actions": ["iam:policy:write"]
}
```

List and remove attachments:

```bash
curl https://api.hexelstudio.com/v1/service-users/<service-user-id>/policies \
  -H "Authorization: Bearer $ACCESS_TOKEN"

curl -X DELETE https://api.hexelstudio.com/v1/service-users/<service-user-id>/policies/<attachment-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

A service user can also get permissions by [assuming a role](https://hexelstudio.com/docs/identity/roles), for a limited time.

## API keys

An API key is a single secret string. Send it in the `X-API-Key` header, or exchange it for an access token. See [Authenticate to the API](https://hexelstudio.com/docs/identity/authenticate).

### Create an API key

<CodeGroup>

```bash curl
curl -X POST https://api.hexelstudio.com/v1/service-users/<service-user-id>/api-keys \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "github-actions",
    "description": "Deploy workflow",
    "expires_at": "2027-01-31T00:00:00Z"
  }'
```

```typescript TypeScript
const serviceUserId = "<service-user-id>";
const res = await fetch(
  `https://api.hexelstudio.com/v1/service-users/${serviceUserId}/api-keys`,
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.ACCESS_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "github-actions",
      description: "Deploy workflow",
      expires_at: "2027-01-31T00:00:00Z",
    }),
  },
);
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const { api_key, id, expires_at } = await res.json();
// Store api_key in your secret manager now: it is never shown again.
```

```python Python
import os
import requests

service_user_id = "<service-user-id>"
resp = requests.post(
    f"https://api.hexelstudio.com/v1/service-users/{service_user_id}/api-keys",
    headers={"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}"},
    json={
        "name": "github-actions",
        "description": "Deploy workflow",
        "expires_at": "2027-01-31T00:00:00Z",
    },
    timeout=10,
)
resp.raise_for_status()
key = resp.json()
# Store key["api_key"] in your secret manager now: it is never shown again.
```

</CodeGroup>

<ParamField body="name" type="string" required>
  A name that tells you where the key is used.
</ParamField>
<ParamField body="description" type="string">
  Optional free text.
</ParamField>
<ParamField body="expires_at" type="string" default="90 days from now">
  When the key stops working, as an RFC 3339 timestamp. It must be in the future and no later than the end of the day (UTC) one year from now. Otherwise the request fails with `400`.
</ParamField>

The response is `201`:

```json
{
  "api_key": "studio_live_9c1d0e7f...",
  "id": "ak_3a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d",
  "key_prefix": "studio_live_9c1d0e7f",
  "expires_at": "2027-01-31T00:00:00Z"
}
```

<Warning>
`api_key` is shown only in this response. Hexel Studio stores a hash of it and can't show it again. If you lose it, create a new key and revoke the old one.
</Warning>

Every new key expires. `expires_at` is always set in the response, to your value or to the default 90 days.

### List API keys

```bash
curl https://api.hexelstudio.com/v1/service-users/<service-user-id>/api-keys \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

The list holds the keys that aren't revoked, including expired ones. Each key shows `id`, `name`, `description`, `key_prefix`, `service_user_id`, `organization_id`, `expires_at`, `last_used_at`, `created_by_user_id` and `created_at`, never the key itself. Use `key_prefix` to find which key a system holds. `last_used_at` is approximate.

Some keys created before October 2026 have no `expires_at`. They work until you revoke them. Replace them with expiring keys.

### Revoke an API key

```bash
curl -X DELETE https://api.hexelstudio.com/v1/service-users/<service-user-id>/api-keys/<key-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

The response is `204`. See [What revocation does](#what-revocation-does).

### Replace a key

API keys aren't rotated in place. To replace one without downtime:

<Steps>
  <Step title="Create a new key">
    Create a key for the same service user and store it where the old one is used.
  </Step>
  <Step title="Deploy">
    Roll out the new key to every system that uses the old one.
  </Step>
  <Step title="Revoke the old key">
    Check that the old key's `last_used_at` has stopped advancing, then revoke it.
  </Step>
</Steps>

## OAuth clients

An OAuth client is a `client_id` and `client_secret` pair for the OAuth 2.0 client credentials grant. Use it with tools and libraries that speak OAuth. See [Use OAuth client credentials](https://hexelstudio.com/docs/identity/authenticate#use-oauth-client-credentials).

### Create an OAuth client

```bash
curl -X POST https://api.hexelstudio.com/v1/service-users/<service-user-id>/oauth-clients \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "billing-sync", "description": "Nightly billing export"}'
```

<ParamField body="name" type="string" required>
  A name for the client.
</ParamField>
<ParamField body="description" type="string">
  Optional free text.
</ParamField>
<ParamField body="expires_at" type="string">
  Optional RFC 3339 timestamp after which the client can't get tokens. Without it, the client doesn't expire.
</ParamField>

The response is `201`:

```json
{
  "client_id": "client_7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b",
  "client_secret": "studio_secret_4f3e2d1c...",
  "name": "billing-sync"
}
```

<Warning>
`client_secret` is shown only in this response and when you rotate it. Store it in a secret manager.
</Warning>

### List OAuth clients

```bash
curl https://api.hexelstudio.com/v1/service-users/<service-user-id>/oauth-clients \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

The list holds the clients that aren't revoked. Each shows `client_id`, `name`, `description`, `client_secret_prefix`, `is_active`, `expires_at`, `last_used_at`, `rotated_at`, `created_by_user_id` and `created_at`, never the secret.

### Rotate a client secret

```bash
curl -X POST https://api.hexelstudio.com/v1/service-users/<service-user-id>/oauth-clients/<client-id>/rotate \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{
  "client_id": "client_7e6d5c4b3a2f1e0d9c8b7a6f5e4d3c2b",
  "client_secret": "studio_secret_8a7b6c5d..."
}
```

The `client_id` stays the same. The old secret stops working at once for new tokens, so update every system that uses it before you rotate, or rotate during a quiet period. Access tokens issued with the old secret stay valid until they expire (at most 15 minutes). To cut them off too, [revoke the client](#revoke-an-oauth-client) and create a new one.

### Revoke an OAuth client

```bash
curl -X DELETE https://api.hexelstudio.com/v1/service-users/<service-user-id>/oauth-clients/<client-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

The response is `204`. A revoked client can't be restored or rotated. A key or client ID that isn't this service user's answers `404` with `{"error": "credential not found"}`.

## Disable or delete a service user

Disable a service user to stop it without losing its setup:

```bash
curl -X PATCH https://api.hexelstudio.com/v1/service-users/<service-user-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"is_active": false}'
```

While it's disabled, none of its API keys or OAuth clients can get a token, and its existing tokens are refused. Send `{"is_active": true}` to enable it again.

Delete it when you no longer need it:

```bash
curl -X DELETE https://api.hexelstudio.com/v1/service-users/<service-user-id> \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

The response is `204`. Deleting revokes all its API keys and OAuth clients, and removes its policy attachments and group memberships. It can't be undone.

## What revocation does

When you revoke an API key or OAuth client, or disable or delete a service user:

- The credential can't get new tokens. An API key sent in `X-API-Key` is refused within seconds.
- Access tokens the credential already issued are refused by Hexel Studio's identity and access APIs. Allow up to 30 seconds.
- Some Hexel Studio products may accept already-issued tokens until they expire, at most 15 minutes after they were issued.
- [Role sessions](https://hexelstudio.com/docs/identity/roles) the service user started are not ended by revoking one credential. They last until they expire, or until you [revoke the role's sessions](https://hexelstudio.com/docs/identity/roles#revoke-sessions). Disabling or deleting the service user ends them.

If a key or secret has leaked, revoke it first, then check the [audit log](https://hexelstudio.com/docs/identity/audit-logs) for what it did.

## Limits

- Creating, rotating and revoking API keys and OAuth clients counts against a limit of **20 requests a minute** for each caller, on top of the general write limit.
- A token request with one API key or OAuth client is limited to **60 a minute**.
- API keys expire after 90 days by default and after at most one year.

Over a limit you get `429` with a `Retry-After` header. See [Limits](https://hexelstudio.com/docs/identity/limits).
