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

# Service accounts and tokens

> Give a backend its own identity: create a service account, exchange its API key for a short-lived bearer token, and check what the token can do.

A service account is an identity for software, not for a person: a CI job, a trading bot or a long-running integration. It belongs to one workspace, holds its own role and owns its own API keys. Your backend can send a service-account key as `X-API-Key`, like any key ([Authentication](https://tetrafi.mintlify.site/authentication.md)), or exchange it for a short-lived bearer token with the standard OAuth 2.0 client-credentials grant, so an OAuth client library can authenticate without TetraFi-specific code.

> [!NOTE]
>
> The API may change until launch. Every change is listed in the [Changelog](https://tetrafi.mintlify.site/changelog.md).

## Create a service account

1. In the app, open **Identity & access**, then **Service accounts**.
2. Select **Create service account**, give it a **Name** and choose its **Role**. **Description** and **Expires** are optional.
3. Copy the account's user ID: it is the `client_id` of the token request.
4. Open the account, then **API keys**, and select **New key**. In **Create service-account key**, give it a **Key name** and select **Create key**.
5. Copy the key. It is shown once and is the `client_secret` of the token request. Store it in your secret manager, never in source code or a tracked file.

You must be a workspace owner or admin with permission to manage credentials. Service accounts and their keys are managed only from a signed-in session: no API key or token can create another credential.

### Its sponsor

Every service account has a sponsor: the member who is accountable for it, by default the one who created it. The account can never do more than its sponsor can do now: its permissions are its role intersected with the sponsor's current permissions, checked on every request. If the sponsor's own permissions shrink, the account's shrink with them.

When the sponsor stops being a current, unsuspended member of the workspace, the account's keys can no longer trade from organisation wallets: those orders, and later steps of a trade already placed, are refused with `409 SERVICE_ACCOUNT_SPONSOR_NOT_CURRENT`. Give the account a current member as sponsor to resume.

## Exchange the key for a token

Send a form-encoded `POST /api/v1/oauth/token` ([RFC 6749, section 4.4](https://www.rfc-editor.org/rfc/rfc6749#section-4.4)):

| Field | Value |
| - | - |
| `grant_type` | `client_credentials` |
| `client_id` | The service account's user ID. |
| `client_secret` | One of its API keys, the full `tfk_live_…` or `tfk_test_…` value. |

You can send `client_id` and `client_secret` in an HTTP Basic `Authorization` header instead of the form. The answer is the standard token response:

| Field | Meaning |
| - | - |
| `access_token` | The bearer token. |
| `token_type` | `Bearer`. |
| `expires_in` | Seconds until the token expires, at most 900 (15 minutes). |
| `scope` | What the token may do, space-separated. |

There is no refresh token: when a token is about to expire, exchange the key again. Keep tokens in memory only; the key is the credential to protect. The script [`samples/http/oauth.sh`](https://github.com/tetrafi/docs/blob/main/samples/http/oauth.sh) runs every step on this page:

```bash cURL theme={null}
# Exchange the key for a bearer token. expires_in is in seconds (at most 900): ask for a new
# token before it runs out, and never store one.
response=$(post_token "$TETRAFI_CLIENT_SECRET")
status=${response##*$'\n'}
token_body=${response%$'\n'*}
if [[ $status != 200 ]]; then
  jq -c '{error, error_description}' <<<"$token_body" >&2
  exit 1
fi
access_token=$(jq -r '.access_token' <<<"$token_body")
jq -c '{token_type, expires_in, scope}' <<<"$token_body" >&2
```

```json Output on test networks theme={null}
{"token_type":"Bearer","expires_in":900,"scope":"compliance_read dashboard_read flow_read orders_read permission_requests_read permission_requests_write prices_read settlements_read trades_read"}
```

## Check what the token can do

`GET /api/v1/me` with the token answers for the key it was exchanged from, the same way it answers that key sent as `X-API-Key` ([Send the key](https://tetrafi.mintlify.site/authentication.md#send-the-key)):

| Field | Meaning |
| - | - |
| `authKind` | `apiKey`: the answer describes an API key. |
| `auth` | `oauthClientCredentials` for a token; `apiKey` when the key itself is sent. |
| `keyId` | The key the token was exchanged from. |
| `workspaceId` | The one workspace the token works in. |
| `scopes` | What the token may do now: the scopes it was issued, narrowed by the key's and its sponsor's current permissions. |

`organisationWallets` is the key's organisation-wallet trading. Its `blockedBy`, when present, is the code an organisation-wallet order would be refused with now ([Check what a key can trade from](https://tetrafi.mintlify.site/authentication.md#check-what-a-key-can-trade-from)).

```bash cURL theme={null}
# Send the token as a bearer. GET /api/v1/me answers for the key the token was exchanged from:
# its id, its workspace, the scopes the token may use now and, under organisationWallets,
# blockedBy: the code an organisation-wallet order would be refused with right now.
me=$(curl -sS "$TETRAFI_BASE_URL/api/v1/me" \
  -H "Authorization: Bearer $access_token" \
  -H "Accept: application/json" \
  -w $'\n%{http_code}')
me_status=${me##*$'\n'}
me=${me%$'\n'*}
if [[ $me_status != 200 ]]; then
  printf '%s\n' "$me" >&2
  exit 1
fi
jq -c '{authKind, auth, keyId, workspaceId, scopes, blockedBy: .organisationWallets.blockedBy}' <<<"$me" >&2
```

```json Output on test networks theme={null}
{"authKind":"apiKey","auth":"oauthClientCredentials","keyId":"322229a8","workspaceId":"5f56c3dd-…","scopes":["compliance_read","dashboard_read","flow_read","orders_read","permission_requests_read","permission_requests_write","prices_read","settlements_read","trades_read"],"blockedBy":"VENUE_TRADE_SCOPE_REQUIRED"}
```

The scopes can shrink while a token is valid: `/me` reads them live, so call it again instead of trusting the token answer's `scope` for the token's whole life.

## Send the token

Send the token as `Authorization: Bearer <access_token>` on every request, in place of `X-API-Key`. It works in the service account's workspace only.

```bash cURL theme={null}
# Every other request takes the same header. Here, the workspace's latest order (orders_read).
orders=$(curl -sS "$TETRAFI_BASE_URL/api/v1/orders?limit=1" \
  -H "Authorization: Bearer $access_token" \
  -H "Accept: application/json" \
  -w $'\n%{http_code}')
orders_status=${orders##*$'\n'}
orders=${orders%$'\n'*}
if [[ $orders_status != 200 ]]; then
  printf '%s\n' "$orders" >&2
  exit 1
fi
jq -c '{orders: [.items[] | {orderId, kind, status, terminal}]}' <<<"$orders" >&2
```

```json Output on test networks theme={null}
{"orders":[{"orderId":"ba5a2e31-…","kind":"basicTrade","status":"pending","terminal":false}]}
```

## When the exchange is refused

A refused exchange answers the RFC 6749 error body, `{"error", "error_description", "error_uri"}`, not the [JSON error body](https://tetrafi.mintlify.site/trade/errors.md#read-a-refusal) of other API refusals:

* **`invalid_client`, 401.** The `client_id` or `client_secret` is wrong, or the key is not an active key of an active service account. The answer carries a `WWW-Authenticate: Basic` challenge. Check both values, and create a new key if this one was revoked or has expired.
* **`invalid_grant`, 400.** `grant_type` is missing, or the form cannot be read. Send the three fields, form-encoded.
* **`unsupported_grant_type`, 400.** `grant_type` is not `client_credentials`.
* **`unauthorized_client`, 400.** The key may not be exchanged: it requires request signing or holds venue trading, and `error_uri` ends with `#SIGNED_KEY_NOT_EXCHANGEABLE`. Use another key of the account, or send this one as a signed `X-API-Key` ([Sign requests](https://tetrafi.mintlify.site/authentication.md#sign-requests)).
* **`temporarily_unavailable`, 503.** TetraFi could not read the account's current authority. Retry shortly.

A key with request signing on is never exchanged, because a bearer token cannot carry a request signature:

```bash cURL theme={null}
# A key with request signing on is never exchanged: it signs each request itself instead.
# The refusal is 400 unauthorized_client, and error_uri ends with #SIGNED_KEY_NOT_EXCHANGEABLE.
signed=skipped
if [[ -n ${TETRAFI_SIGNED_CLIENT_SECRET:-} ]]; then
  refused=$(post_token "$TETRAFI_SIGNED_CLIENT_SECRET")
  refused_status=${refused##*$'\n'}
  refused=${refused%$'\n'*}
  jq -c --arg status "$refused_status" '{status: ($status | tonumber), error, error_uri}' <<<"$refused" >&2
  if [[ $refused_status != 400 ]] || ! jq -e '.error == "unauthorized_client"
      and (.error_uri // "" | endswith("#SIGNED_KEY_NOT_EXCHANGEABLE"))' <<<"$refused" >/dev/null; then
    exit 1
  fi
  signed=SIGNED_KEY_NOT_EXCHANGEABLE
fi
```

```json Output on test networks theme={null}
{"status":400,"error":"unauthorized_client","error_uri":"https://tetrafi.mintlify.app/reference/error-codes#SIGNED_KEY_NOT_EXCHANGEABLE"}
```

A token used after `expires_in` has passed is refused with `401` and the challenge `WWW-Authenticate: Bearer error="invalid_token"` ([RFC 6750](https://www.rfc-editor.org/rfc/rfc6750#section-3)): exchange the key for a new one. A refused API key carries no challenge.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.