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

# Service Tokens

> Create, rotate, and revoke long-lived API tokens for service-to-service integrations

## Overview

**Service tokens** are legacy long-lived API tokens scoped to an account (all of its
advertisers) or a single advertiser. Existing tokens continue to work and remain
revocable during migration. For a new headless integration, create a dedicated service
user with the intended WorkOS access and issue a user API key as that user. Use M2M
when the integration specifically requires OAuth client credentials and a non-user
machine identity.

Each customer-owned service token also establishes a **workload identity** for
the software using that credential. `workloadUid` identifies the workload;
`credentialVersion` identifies the exact credential; and `adcpCallerUri` is the
stable caller identity used for AdCP governance delegation. Internal Apostra
system tokens do not receive buyer workload identities.

They are distinct from user API keys, which belong to an individual or dedicated
service user. Service tokens survive user offboarding and can be scoped down to a
single advertiser with a specific role, but they are a compatibility credential rather
than the default for new integrations.

New user API keys do not have a separate read/write/admin selector. Apostra assigns
API access automatically, then applies the owner's current account, advertiser, and
resource authorization on every request. To narrow a dedicated service user's key,
change that user's authorization in WorkOS rather than selecting a second key role.

| User API key                                                  | Legacy service token                         |
| ------------------------------------------------------------- | -------------------------------------------- |
| Tied to a human or dedicated service user in one organization | Tied to an account or advertiser             |
| Follows that user's current authorization                     | Survives user changes                        |
| Multiple least-privilege keys per user                        | Many per account                             |
| Default for: scripts and headless integrations                | Compatibility only for existing integrations |

Both are bearer credentials — every request sends `Authorization: Bearer scope3_<token>`.

### Token format

A service token is a single opaque string with three parts joined by underscores:

```
scope3_<accessClientId>_<accessClientSecret>
```

* The `scope3_` prefix lets us recognize the token at the edge.
* `accessClientId` is a non-secret identifier for this credential version — safe to log, surface in admin UIs, and reference in audit trails.
* `accessClientSecret` is the credential — only shown once, at creation.

<Warning>
  The full token (`fullToken` field on the create response) is shown **exactly
  once**, on the response to `POST /service-tokens`. We store only a hash; we
  cannot show it to you again. If you lose it, revoke the token and create a
  new one.
</Warning>

## Prerequisites

<Steps>
  <Step title="An authorized user API key">
    You need an existing credential whose owner may manage tokens — a user API key from
    [app.apostra.com/user-api-keys](https://app.apostra.com/user-api-keys),
    or another service token with `ADMIN` role.
  </Step>

  <Step title="Decide the scope">
    Pick `CUSTOMER` (acts across every advertiser) or `ADVERTISER` (limited
    to one advertiser). For advertiser-scoped tokens, you also choose a role
    (`ADMIN`, `READ_WRITE`, `READ`) and an expiry between 1 and 365 days.
  </Step>

  <Step title="A secret manager">
    Plan where you'll store `fullToken`. Service tokens act on your
    organization's behalf — keep them in Google Secret Manager, AWS Secrets
    Manager, HashiCorp Vault, or equivalent. Never commit them to git.
  </Step>
</Steps>

All examples below use:

```
BASE = https://api.apostra.com/api/v2
AUTH = Authorization: Bearer scope3_<your_token>
```

<Note>
  Service-token endpoints live under `/api/v2/service-tokens`, not under
  `/api/v2/buyer/...` or `/api/v2/storefront/...`. They are mounted on the v2
  shared router because the same token-management surface works for buyer
  and storefront accounts alike. Most other v2 endpoints sit under a
  buyer/storefront mount — service tokens are the exception.
</Note>

## Step 1: Create a service token

`POST /service-tokens` returns the new token, including the one-time-only
`fullToken` field. Capture it before doing anything else.

<CodeGroup>
  ```bash Account-scoped theme={null}
  curl -X POST "$BASE/service-tokens" \
    -H "Authorization: Bearer scope3_<your_token>" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "Nightly ETL — Snowflake export",
      "description": "Pulls reporting data into the warehouse every 02:00 UTC",
      "expiresInDays": 365,
      "scope": "CUSTOMER"
    }'
  ```

  ```bash Advertiser-scoped theme={null}
  curl -X POST "$BASE/service-tokens" \
    -H "Authorization: Bearer scope3_<your_token>" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "Acme — campaign automation",
      "scope": "ADVERTISER",
      "advertiserName": "Acme Co.",
      "role": "READ_WRITE",
      "expiresInDays": 90
    }'
  ```
</CodeGroup>

```json Response theme={null}
{
  "id": "12345",
  "customerId": "cust_abc",
  "advertiserId": null,
  "userId": null,
  "name": "Nightly ETL — Snowflake export",
  "description": "Pulls reporting data into the warehouse every 02:00 UTC",
  "accessClientId": "ak_01HXYZ...",
  "fullToken": "scope3_ak_01HXYZ..._sk_live_9c8d7e6f...",
  "expiresAt": "2027-04-26T00:00:00.000Z",
  "createdAt": "2026-04-26T16:30:00.000Z",
  "role": null,
  "createdByUserId": "98765",
  "workloadUid": "4f7d713a-94a5-4f64-9d55-13f99bb2dc42",
  "credentialVersion": 1,
  "replacesServiceTokenId": null,
  "adcpCallerUri": "https://api.apostra.com/api/v2/buyer/workloads/4f7d713a-94a5-4f64-9d55-13f99bb2dc42"
}
```

<Warning>
  Capture `fullToken` and store it in your secret manager **immediately**.
  After this response, only `accessClientId` is retrievable.
</Warning>

### Request body

| Field            | Type    | Required    | Notes                                                               |
| ---------------- | ------- | ----------- | ------------------------------------------------------------------- |
| `name`           | string  | no          | Human-readable label, max 255 chars                                 |
| `description`    | string  | no          | Free-form, max 1000 chars                                           |
| `expiresInDays`  | integer | conditional | 1–365. Required when `scope: ADVERTISER`                            |
| `scope`          | enum    | no          | `CUSTOMER` (default) or `ADVERTISER`                                |
| `advertiserName` | string  | conditional | Required when `scope: ADVERTISER`                                   |
| `role`           | enum    | conditional | `ADMIN`, `READ_WRITE`, or `READ`. Required when `scope: ADVERTISER` |

### Selecting an advertiser per request

Account-scoped (`CUSTOMER`) tokens and user API keys select the advertiser to
act on by sending the `x-scope3-seat-id` request header with the numeric
advertiser ID:

```bash theme={null}
curl "$BASE/campaigns" \
  -H "Authorization: Bearer scope3_<your_token>" \
  -H "x-scope3-seat-id: 88421"
```

<Note>
  The header name intentionally keeps its legacy `seat` spelling — it is a
  stable wire contract that predates the seat→advertiser rename. The value it
  carries is the advertiser ID. Advertiser-scoped tokens don't need it: they
  are scoped to one advertiser at creation, and a header naming a different
  advertiser is rejected with `403`.
</Note>

## Step 2: List or fetch existing tokens

Use the list endpoint to see what tokens exist for your account (or for a
specific advertiser). The list view never includes the secret — only
`accessClientId`, workload and credential identity, scope, expiry, and audit
metadata.

```bash theme={null}
curl "$BASE/service-tokens?take=50&skip=0" \
  -H "Authorization: Bearer scope3_<your_token>"
```

| Query param       | Type    | Default | Notes                  |
| ----------------- | ------- | ------- | ---------------------- |
| `advertiserId`    | string  | —       | Filter by advertiser   |
| `includeArchived` | boolean | `false` | Include revoked tokens |
| `take`            | integer | `50`    | Page size, max 100     |
| `skip`            | integer | `0`     | Offset                 |

To inspect a single token (for example, before extending its expiry):

```bash theme={null}
curl "$BASE/service-tokens/12345" \
  -H "Authorization: Bearer scope3_<your_token>"
```

You can also rename a token or **extend** its expiry through the update
endpoint. You cannot shorten the expiry through this endpoint — to
invalidate a token sooner, archive it (Step 4).

```bash theme={null}
curl -X PUT "$BASE/service-tokens/12345" \
  -H "Authorization: Bearer scope3_<your_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Nightly ETL — Snowflake export (renamed)",
    "expiresInDays": 365
  }'
```

## Step 3: Rotate a token

Treat service tokens like any other long-lived credential and rotate on a
schedule. The pattern is "create new, deploy, revoke old" — never edit the
existing token in place.

`POST /service-tokens` creates a new workload identity, so the manual rotation
below starts a new `workloadUid`; retain the old and new ids in your deployment
record so their activity can be audited separately. A registered buyer-agent API
key is the exception: rotate it through
[`POST /buyer-agent-principals/rotate`](/v2/setup/buyer-agent-credentials#api-key)
so the server preserves verified workload lineage and returns the replacement
secret once. Do not create a successor for that workflow through this endpoint.

<Steps>
  <Step title="Create the new token">
    `POST /service-tokens` with the same scope and role as the old one.
    Capture `fullToken` and `id` from the response.
  </Step>

  <Step title="Deploy to consumers">
    Update your secret manager and roll the new token out to every service
    that holds the old one.
  </Step>

  <Step title="Confirm rollout completed">
    Wait until you've confirmed all consumers have switched — for example,
    by checking deploy status, draining staging traffic, or watching error
    rates after a forced restart. Don't revoke the old token until you're
    sure nothing still depends on it.
  </Step>

  <Step title="Revoke the old token">
    `DELETE /service-tokens/<old_id>`. Any service still using the old
    token will start receiving `401 Unauthorized`, which is your final
    signal that rollout missed somewhere.
  </Step>
</Steps>

<Tip>
  Set `expiresInDays` to a value shorter than your rotation window so an
  unrotated token expires on its own — defense-in-depth for the case where
  a token is forgotten.
</Tip>

## Step 4: Revoke a token

Revocation is a soft delete — the token stops authenticating immediately,
but its audit row is preserved.

```bash theme={null}
curl -X DELETE "$BASE/service-tokens/12345" \
  -H "Authorization: Bearer scope3_<your_token>"
```

Returns `204 No Content`.

<Warning>
  **Rotate on suspected leak.** If a token might have been exposed
  (CI log, shared screen, leaked dependency), revoke it via
  `DELETE /service-tokens/:id` immediately, then issue a replacement.
  Do not wait for confirmation.
</Warning>

## Best practices

* **Use advertiser scope when possible.** An `ADVERTISER`-scoped token with `READ` role
  cannot move spend, even if exfiltrated. Default to least-privilege: pick
  the narrowest scope and lowest role that lets the integration do its job.
* **One token per workload.** If two services have different lifecycles or
  different blast radii, give them separate tokens. That way a leak or
  rotation only affects one workload at a time.
* **Treat as a credential.** Never commit tokens to git, never paste them
  into chat or email, never hard-code them in client-side bundles.
* **Multi-tenant tools have two patterns.**
  * *One token per managed advertiser.* Create an `ADVERTISER`-scoped token
    per managed advertiser with the minimum role each integration needs.
    Best when the advertisers have different administrators or you want
    clean per-tenant audit trails.
  * *One account-scoped token.* Use `scope: CUSTOMER` and rely on
    per-request advertiser selection. Best when a single team owns
    automation across every managed advertiser.
  * Either way, never share a single token across unrelated tenants — it
    conflates audit trails and forces a global revocation if any tenant is
    compromised.

## Endpoint reference

All paths are relative to `https://api.apostra.com/api/v2`.

| Method   | Path                  | Purpose                                           |
| -------- | --------------------- | ------------------------------------------------- |
| `GET`    | `/service-tokens`     | List service tokens                               |
| `POST`   | `/service-tokens`     | Create a service token (returns `fullToken` once) |
| `GET`    | `/service-tokens/:id` | Get a single service token                        |
| `PUT`    | `/service-tokens/:id` | Update name, description, or extend expiry        |
| `DELETE` | `/service-tokens/:id` | Archive (revoke) a service token                  |

## Related

<CardGroup cols={2}>
  <Card title="Authentication" href="/v2/authentication" icon="key">
    Bearer token format, header conventions, and how personal API keys
    differ from service tokens.
  </Card>

  <Card title="Errors" href="/v2/reference/errors" icon="triangle-exclamation">
    Status codes and JSON error shapes returned by the service token
    endpoints — including auth and validation failures.
  </Card>
</CardGroup>
