# Company API

What a customer company's own tools call with a company API key - its audit trail, people, sessions, access policy, roles, groups and more, each behind a scope.

The company API is what a customer company's own tools call: its compliance archive, its SIEM jobs, the script that signs a leaver out. It answers for one company, the one the key belongs to, and only what the key's scopes allow. There is no workspace and no session in it, and your team is not in the loop.

This builds on Enterprise SSO for the company: [set that up](/sign-in-setup/enterprise-sso) first, if you have not.

> **Note:**
  [Enterprise SSO](/authentication/enterprise-sso) set up for the company, and a
  company API key: made on the Single sign-on screen by whoever manages the
  company, or in the console for one of its service accounts.


## Calling it

Every call sends the key as a bearer token to `https://<your server>/api/v1/company`:

```bash
curl -H "Authorization: Bearer bbck_..." "https://<your server>/api/v1/company/"
```

The key is `bbck_` followed by your org, the key's id and 40 random characters. The prefix lets a secret scanner tell it from your own API keys. It is shown once, when it is made, and stored only as a hash: lose it and make another.

`GET /` answers the company and the key, which is the quickest way to check a key works:

```json
{
  "company": {
    "enterpriseId": "...",
    "name": "Globex",
    "domains": [
      {
        "domain": "globex.com",
        "status": "verified",
        "source": "workos",
        "verifiedAt": "2026-10-15T09:12:44.000Z",
        "lastError": null
      }
    ]
  },
  "key": {
    "keyId": "...",
    "name": "Nightly trail",
    "scopes": ["audit:read"],
    "scopesInEffect": ["audit:read"],
    "owner": "service_account",
    "expiresAt": null
  }
}
```

Each domain carries WorkOS's verdict: `status` is `pending`, `verified` or `failed`, `source` says who verified it (`workos`), and `lastError` why the last check did not verify it. Only a `verified` domain counts. `scopesInEffect` is what the key may use right now. For a service account's key it is always `scopes`; for someone's own key it is the part of `scopes` their [company roles](/authentication/enterprise-groups-and-roles) still reach.

An answer is the JSON body itself, as in the examples on this page, with no envelope around it. A refusal is `{ "error": true, "code": "...", "message": "..." }` with a sentence you can show.

### A workload token instead of a key

A company's CI job or cloud workload can call with no stored key: it exchanges its own short-lived token at `POST /api/v1/company/token` for a company API token (`bbct_`, at most an hour), when the token matches a workload credential on one of the company's service accounts ([workload identity](/authentication/enterprise-workload-identity)). The token goes where a key goes, `Authorization: Bearer bbct_...`, acts as its credential with the scopes it was given, and is refused with the same codes as a key, in sentences that say "token". `GET /` answers `token` in place of `key`:

```json
{
  "company": { "enterpriseId": "...", "name": "Globex", "domains": [] },
  "key": null,
  "token": {
    "tokenId": "...",
    "credential": {
      "credentialId": "...",
      "name": "deploy",
      "preset": "github_actions"
    },
    "serviceAccount": { "serviceAccountId": "...", "name": "deploy-bot" },
    "scopes": ["audit:read"],
    "expiresAt": "2026-10-19T10:15:00.000Z",
    "subject": "repo:globex-eng/deploy:ref:refs/heads/main"
  }
}
```

No route of the company API makes, changes or lists keys, service accounts or workload credentials, so neither a key nor a token can make another.

## Scopes

A key carries only the scopes it was given, chosen when it is made. Reads come first:

| Scope            | Lets the key                                                                                                                                                                     |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `audit:read`     | Read the company's trail, export it as CSV, and, with company audit logs on, export it from WorkOS                                                                               |
| `people:read`    | List the company's people and whether each is active, each person's sessions, requests to join, accounts on its domains not yet claimed, and people outside it in its workspaces |
| `policy:read`    | Read the access policy                                                                                                                                                           |
| `insights:read`  | Read the identity insights                                                                                                                                                       |
| `admins:read`    | Read who holds which company role, the company's roles, how they stand in WorkOS, and its IT and security contacts                                                               |
| `groups:read`    | Read the company's groups, everyone in each (named, linked or through a group inside it), what each gives and who manages it                                                     |
| `apps:read`      | Read the apps and agents its people connected, and the identity provider that approves agents                                                                                    |
| `streams:read`   | Read its log streams and how each is doing, BuildBase's and WorkOS's                                                                                                             |
| `people:write`   | Sign one person, or everyone, out, or end one session; approve or decline requests to join; claim accounts on its domains                                                        |
| `passkeys:write` | Reset a person's passkeys: remove them for the app and sign the person out                                                                                                       |
| `policy:write`   | Change the access policy, its sign-in methods included                                                                                                                           |
| `admins:write`   | Give and take company roles, except the identity provider's, make roles, and publish them to WorkOS                                                                              |
| `groups:write`   | Make, change and delete groups, nest them, change who is in one, name its managers; a group that gives a role (or is in one that does) needs `admins:write`                      |
| `apps:write`     | Disconnect an app or agent, for one person or everyone, and switch agents through the identity provider off                                                                      |
| `streams:write`  | Add, change, test, rotate and remove log streams                                                                                                                                 |

No scope manages keys or service accounts: a key cannot make another key, so a leaked one stays as small as it was made.

## Routes

| Route                                                                                                                              | Scope                         | Answers                                                                                                                                                                                                                                            |
| ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /`                                                                                                                            | any                           | The company and the key. The company's `sso` lists its identity providers by `ref` and name, and which signs in each domain (read only: no key changes it)                                                                                         |
| `GET /audit`                                                                                                                       | `audit:read`                  | `{ entries, nextCursor }`, filtered by `action`, `from`, `to`, `limit` and `cursor` as in the console                                                                                                                                              |
| `GET /audit/export`                                                                                                                | `audit:read`                  | The same as CSV, at most 50,000 rows a request                                                                                                                                                                                                     |
| `POST /audit/exports`, `GET /audit/exports/<export id>`                                                                            | `audit:read`                  | WorkOS's export, with company audit logs on ([exports](#exports))                                                                                                                                                                                  |
| `GET /people`                                                                                                                      | `people:read`                 | `{ people, page, limit, total, hasNextPage }`, newest first, searched with `search`, 50 a `page` unless `limit` (up to 100) says otherwise ([every person, page by page](#every-person-page-by-page))                                              |
| `POST /sign-out`                                                                                                                   | `people:write`                | `{ "userId": "..." }` signs one person out, `{ "everyone": true }` everyone, and `{ "outsideMethods": true }` ends the sessions that began a way the sign-in methods do not allow, one of the three; answers `{ people, sessions }`                |
| `POST /people/<user id>/passkeys/reset`                                                                                            | `passkeys:write`              | Removes that person's passkeys for the app and signs them out; answers `{ removed, sessions }` ([sign-in methods and passkeys](#sign-in-methods-and-passkeys))                                                                                     |
| `GET /people/<user id>/sessions`                                                                                                   | `people:read`                 | `{ sessions }`: one person's live sessions, newest first ([one person's sessions](#one-person-s-sessions))                                                                                                                                         |
| `DELETE /people/<user id>/sessions/<session ref>`                                                                                  | `people:write`                | Ends that one session; answers `{ ended: 1 }`                                                                                                                                                                                                      |
| `GET /join-requests`                                                                                                               | `people:read`                 | `{ requests, pending, max }`: waiting requests first, then the last decided                                                                                                                                                                        |
| `POST /join-requests/<request id>/approve`, `POST /join-requests/<request id>/decline`                                             | `people:write`                | `{ request }`, and on approval how many `workspaces` they joined                                                                                                                                                                                   |
| `GET /unclaimed`                                                                                                                   | `people:read`                 | `{ people, total, domains }`: accounts on the company's domains that are not its people, 50 a `page`, searched with `search`                                                                                                                       |
| `POST /claim`                                                                                                                      | `people:write`                | `{ "userIds": [...] }` (up to 100) or `{ "all": true }` (up to 100 a call); answers `{ claimed, skipped, remaining }`                                                                                                                              |
| `GET /outsiders`                                                                                                                   | `people:read`                 | `{ outsiders, total }`: members of its workspaces outside the company, with the workspace and role                                                                                                                                                 |
| `GET /policy`, `PUT /policy`                                                                                                       | `policy:read`, `policy:write` | The access policy, as on the Single sign-on screen, with `domainJoin`, `outsiders`, `appTeamSessions` and `signInMethods`, and on `GET` the company's `networkRestriction` ([the company's network](#the-company-s-network))                       |
| `GET /policy/workspaces`, `GET /policy/workspaces/<workspace id>`                                                                  | `policy:read`                 | `{ exceptions, workspaces, max }`: every bound workspace's stricter rules, and the bound workspaces to choose from; or one workspace's rules ([stricter rules for one workspace](/authentication/enterprise-sso#stricter-rules-for-one-workspace)) |
| `PUT /policy/workspaces/<workspace id>`, `DELETE /policy/workspaces/<workspace id>`                                                | `policy:write`                | Sets or replaces one workspace's rules, never looser than the company's (422 `workspace_policy_not_stricter` with each field), answering them with `affected`; or removes them. A workspace bound to another company is 404 `workspace_not_bound`  |
| `GET /insights`                                                                                                                    | `insights:read`               | The insights over `days` (7, 30 or 90)                                                                                                                                                                                                             |
| `GET /admins`                                                                                                                      | `admins:read`                 | `{ admins, max }`, each with its `role` and `roleName`                                                                                                                                                                                             |
| `POST /admins`, `PATCH` and `DELETE /admins/<user id>`                                                                             | `admins:write`                | `{ "userId": "...", "role": "..." }` names someone, `{ "role": "..." }` changes it; answers the list                                                                                                                                               |
| `GET /roles`                                                                                                                       | `admins:read`                 | `{ roles, max }`, the three presets first                                                                                                                                                                                                          |
| `POST /roles`, `PATCH` and `DELETE /roles/<role id>`                                                                               | `admins:write`                | `{ "name": "...", "permissions": { "people": "manage" } }`, and `assignableInWorkOS` to publish it to WorkOS or take it out; answers the role                                                                                                      |
| `GET /workos-roles`                                                                                                                | `admins:read`                 | The company's roles in WorkOS: whether they are in sync, what is published, the slugs its people carry, and the issues ([WorkOS roles in sync](/authentication/enterprise-groups-and-roles#workos-roles-in-sync))                                  |
| `GET /it-contacts`                                                                                                                 | `admins:read`                 | `{ contacts, max, readAt }`: the addresses WorkOS emails about the company's connection, read only ([IT and security contacts](#it-and-security-contacts))                                                                                         |
| `GET /groups`                                                                                                                      | `groups:read`                 | `{ groups, workspaces, max }`                                                                                                                                                                                                                      |
| `POST /groups`, `PATCH` and `DELETE /groups/<group id>`                                                                            | `groups:write`                | As in [company groups](/authentication/enterprise-groups-and-roles#company-groups), with `assignableInWorkOS` and `includes` (the [groups inside it](/authentication/enterprise-groups-and-roles#groups-inside-groups))                            |
| `GET /groups/<group id>`, `GET /groups/<group id>/people?search=&cursor=`                                                          | `groups:read`                 | One group, with `includes`, `includedIn`, `inherited`, `rolesReached`, `managers` and `peopleBy`; and everyone in it, 50 a page, with `how` and `through`                                                                                          |
| `POST /groups/<group id>/members`                                                                                                  | `groups:write`                | `{ "add": [...], "remove": [...] }`, user ids, up to 100 each; answers `{ group, added, removed, applied, pending }`                                                                                                                               |
| `POST /groups/<group id>/managers`, `DELETE /groups/<group id>/managers/<user id>`                                                 | `groups:write`                | `{ "userId": "..." }`; names or removes one of up to 10 [managers](/authentication/enterprise-groups-and-roles#group-managers); answers `{ group }`                                                                                                |
| `GET /connected-apps`, `GET /connected-apps/<client id>`                                                                           | `apps:read`                   | `{ apps }`, and one app's `{ grants }`                                                                                                                                                                                                             |
| `POST /connected-apps/revoke`                                                                                                      | `apps:write`                  | `{ "clientId": "...", "userId": "..." }`, without `userId` for everyone; answers `{ people }`                                                                                                                                                      |
| `GET /agent-access`                                                                                                                | `apps:read`                   | The identity provider that approves agents for the company, and what to enter in it ([agents through the identity provider](#agents-through-the-identity-provider))                                                                                |
| `POST /agent-access/disable`                                                                                                       | `apps:write`                  | Switches it off, ending every agent session it approved; answers the same with `sessionsEnded`                                                                                                                                                     |
| `GET /log-streams`                                                                                                                 | `streams:read`                | `{ streams, companyManages }`                                                                                                                                                                                                                      |
| `GET /log-stream`                                                                                                                  | `streams:read`                | The company's WorkOS log stream, with company audit logs on: `{ stream, retentionDays }`                                                                                                                                                           |
| `POST /log-streams`, `PATCH` and `DELETE /log-streams/<id>`, `POST /log-streams/<id>/rotate-secret`, `POST /log-streams/<id>/test` | `streams:write`               | As on the Single sign-on screen. A new signing secret is answered once. A new stream is refused while company audit logs are on                                                                                                                    |

Each route calls the same code as the Single sign-on screen and the console, so a key sees exactly what the company's admins see: your team's own changes reach it without who made them.

```bash
# The trail's refused sign-ins this week, newest first
curl -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/audit?action=enterprise_sign_in_refused&from=2026-10-12T00:00:00Z"

# Sign a leaver out of everything, including the agents they connected
curl -X POST -H "Authorization: Bearer $COMPANY_KEY" -H "Content-Type: application/json" \
  -d '{"userId":"<user id>"}' \
  "https://<your server>/api/v1/company/sign-out"

# Approve a request to join
curl -X POST -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/join-requests/<request id>/approve"
```

Requests to join, claiming accounts and the people outside the company are described in [domains and joining](/authentication/enterprise-domains-and-joining). A claim signs each person out and emails nobody; `remaining` says how many accounts an `all` claim left for the next call.

## Every person, page by page

`GET /people` answers one page of the company's people, newest first: 50 unless `limit` asks for another number from 1 to 100, from `page` 1 unless `page` names a later one. `total` is how many people match, and `hasNextPage` is `true` while there are more, so an access review reads every page until it is `false`. Without `page` or `limit` the answer is the first page, as it always was. A `page` or `limit` that is not a whole number is read as its default, never refused.

```bash
# The second page of 100 people whose email or name holds "acme"
curl -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/people?search=acme&page=2&limit=100"
```

```json
{
  "people": [
    {
      "userId": "...",
      "email": "ada@acme.com",
      "name": "Ada Lovelace",
      "provisionedBy": "sso",
      "active": true
    }
  ],
  "page": 2,
  "limit": 100,
  "total": 214,
  "hasNextPage": true
}
```

## One person's sessions

`POST /sign-out` ends everything a person has. When one laptop is lost, end that one session instead: the person stays signed in on every other device.

```bash
# Priya's live sessions, newest first
curl -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/people/<user id>/sessions"

# End the one on the lost laptop
curl -X DELETE -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/people/<user id>/sessions/<session ref>"
```

The list answers:

```json
{
  "sessions": [
    {
      "id": "6710b2c4e1f0a9d3c5b7e812",
      "device": { "name": "Priya's MacBook" },
      "browser": "Chrome 141",
      "os": "macOS",
      "ip": "203.0.113.24",
      "place": { "city": "Pune", "region": "Maharashtra", "country": "IN" },
      "signedInAt": "2026-10-16T08:02:11.000Z",
      "lastActiveAt": "2026-10-17T07:40:00.000Z",
      "endsAt": "2026-10-17T16:02:11.000Z",
      "method": "sso",
      "issuedBy": null,
      "current": false
    }
  ]
}
```

| Field           | What it is                                                                                                                                                                                                                                                              |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`            | The session's ref, which `DELETE` takes. Never the session itself, which is a credential                                                                                                                                                                                |
| `device`        | The device's name, or `null`                                                                                                                                                                                                                                            |
| `browser`, `os` | Read from the user agent, such as `Chrome 141` and `iOS 18`, or `null`. A version browsers freeze is left out: `macOS`, `Windows`                                                                                                                                       |
| `ip`, `place`   | Where it was used from. Both are `null` for a session the app's team opened                                                                                                                                                                                             |
| `signedInAt`    | When it began                                                                                                                                                                                                                                                           |
| `lastActiveAt`  | When it was last used                                                                                                                                                                                                                                                   |
| `endsAt`        | When it ends: the earlier of its expiry and your access policy's maximum session length                                                                                                                                                                                 |
| `method`        | How it began: `password`, `magic_link`, `email_code`, `phone_code`, `passkey`, `social`, `wallet`, `sso`, `guest`, `app_team` (your team's session, the console's **Sign in as user** included), or `unknown` for a session opened before sign-in methods were recorded |
| `issuedBy`      | `{ "kind": "app_team", "appKeyName": "..." }` for a session the app's team opened as the person, with the name of their key; `null` otherwise                                                                                                                           |
| `current`       | Whether it is the session making the request, which a key never is                                                                                                                                                                                                      |

Ending one answers `{ "ended": 1 }`. It deletes that session only: the person's other sessions, the apps they connected, the company's Admin Portal sessions and its keys stay. The device is told why at its next request. A session that has expired, or is past your idle limit or maximum length, is not listed, and apps and agents a person connected are under `GET /connected-apps`, not here.

Only the company's own people: anyone else is 404 `not_a_person`, and a ref that is not one of that person's live sessions is 404 `session_not_found`. Ending a session is not refused while the app's team keeps the company's settings, as signing out is not. Each one is in the trail as `enterprise_sessions_revoked` with `scope: "session"`, the device and how it began, and `via: "api"` with the key's name.

## Sign-in methods and passkeys

A company can choose the ways its people sign in when they do not use single sign-on, down to passkeys only ([how it works](/authentication/enterprise-sso#sign-in-methods-without-single-sign-on)). A key with `policy:write` sets the list, one with `people:write` ends the sessions outside it, and one with `passkeys:write` resets a lost passkey:

```bash
# Passkeys or Google, for everyone who does not use single sign-on
curl -X PUT -H "Authorization: Bearer $COMPANY_KEY" -H "Content-Type: application/json" \
  -d '{"signInMethods":{"allowed":["passkey","google"]}}' \
  "https://<your server>/api/v1/company/policy"

# End the sessions that began a way the list does not allow
curl -X POST -H "Authorization: Bearer $COMPANY_KEY" -H "Content-Type: application/json" \
  -d '{"outsideMethods":true}' \
  "https://<your server>/api/v1/company/sign-out"

# Priya lost her phone: remove her passkeys and sign her out
curl -X POST -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/people/<user id>/passkeys/reset"
```

`allowed` takes `password`, `magic_link`, `email_code`, `phone_code`, `passkey`, `google`, `microsoft`, `github`, `linkedin`, `apple`, `custom_oidc`, `ethereum_wallet` and `solana_wallet`, at least one, each once; `"signInMethods": null` clears the list, which allows every way the app offers. Single sign-on is always allowed and is never on the list. `GET /policy` answers it with what the company needs to decide:

```json
{
  "signInMethods": {
    "allowed": ["passkey", "google"],
    "offered": ["password", "magic_link", "passkey", "google"],
    "people": 40,
    "withPasskey": 31,
    "outside": { "sessions": 6, "people": 4 }
  }
}
```

`allowed` is `null` while there is no list; `offered` is the ways the app's sign-in offers, and a way it does not offer is refused at save with 409 `sign_in_method_not_offered`; `withPasskey` counts the company's people who hold a passkey they can sign in with; `outside` counts the live sessions of its people that began a way not on the list. A session whose way in was not recorded is never counted or ended: one opened before sign-in methods were recorded, and a social sign-in opened before its provider (Google, Microsoft, GitHub, LinkedIn, Apple or the app's own OpenID Connect provider) was recorded.

A reset removes the person's passkeys for the app and signs them out, so their next sign-in sets up a new passkey. Every sign-in token they held from before stops working too, so one taken from them can neither open a session nor set up a passkey, and they get an email naming the company that reset their passkeys, with no link or code in it. It answers `{ removed, sessions }`: the passkeys removed and the sessions ended. Someone who is not one of the company's people is 404 `not_a_person`. Each is in the trail with `via: "api"` and the key's name: the list as `enterprise_policy_updated` naming `signInMethods`, the sign-out as `enterprise_sessions_revoked` with `scope: "outside_methods"`, and the reset as `enterprise_passkeys_reset`.

## The company's network

A company keeps its network to its own accounts with its proxy, which adds `allowed-company-ids: <company id>` to every request to the app ([how](/authentication/enterprise-sso-it-admin#keep-your-network-to-your-company-s-accounts)). `GET /policy` answers what to set, and what this very request carried, so a script run from the office checks the proxy:

```json
{
  "networkRestriction": {
    "header": "allowed-company-ids",
    "companyId": "6650f1a2b3c4d5e6f7a8b9c0",
    "hosts": ["api.example.com"],
    "received": {
      "ids": ["6650f1a2b3c4d5e6f7a8b9c0"],
      "namesThisCompany": true,
      "namesThisApp": true,
      "repeated": false,
      "skipped": 0
    }
  }
}
```

`companyId` is the value to put in the header and `hosts` the API host to add it on. `received` is `null` when the request carried no header. `namesThisCompany` says it names your company; `namesThisApp` false means it names no company of this app, so the app ignores it; `repeated` means it came twice, so the proxy adds a copy instead of replacing it; `skipped` counts entries that were not company IDs.

On a network whose header names companies of this app, a key of any other company is refused with 403 `tenant_restricted`, and the refusal is in the trail of the companies the header names, once an hour per key. A request that carries the header twice, one line naming a company of this app, is refused with 400 `tenant_header_invalid`.

## IT and security contacts

WorkOS emails the company's IT and security contacts a setup link when the app's team invites one, a warning before the SAML certificate expires, and an alert when the connection has errors. The list is WorkOS's, up to 20 addresses, and a key reads it with `admins:read`:

```bash
# The company's IT and security contacts; ?refresh=1 reads WorkOS now
curl -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/it-contacts"
```

```json
{
  "contacts": [
    {
      "ref": "3f9a0c2e71b4d5a8",
      "email": "it@globex.com",
      "addedAt": "2026-10-18T09:12:44.000Z"
    },
    {
      "ref": "b07d41e9c3a25f60",
      "email": "secops@globex.com",
      "addedAt": "2026-10-18T09:20:03.000Z"
    }
  ],
  "max": 20,
  "readAt": "2026-10-18T10:02:11.000Z"
}
```

`contacts` is in WorkOS's order, oldest first, and `addedAt` is when WorkOS added the address. `ref` is the app's reference for a contact, never WorkOS's own id. `readAt` is when WorkOS was asked: one read answers for a minute, and `?refresh=1` reads again once the last read is ten seconds old.

There is no route to add or remove a contact. The company's IT team changes the list in WorkOS's Admin Portal, on its single sign-on connection's page after setup, opened with **Change in WorkOS** on the Single sign-on screen ([the page for IT admins](/authentication/enterprise-sso-it-admin#your-company-s-it-and-security-contacts)). WorkOS reports no event for such a change, so it is not in the trail: compare two reads to see one. When WorkOS cannot be read, the route answers 409 `not_configured` (the app's WorkOS account is not set up), 429 `rate_limited` with `Retry-After: 60`, or 502 with WorkOS's code.

## Agents through the identity provider

When the app's team has turned on enterprise-managed authorization, the company's identity provider can approve the AI agents that act for its people ([how](/authentication/enterprise-sso-it-admin#agents-through-your-identity-provider)). A key reads it, and switches it off, for example from an incident runbook. It never turns it on and never sets the issuer: that is for whoever manages the company's Setup, in the app, or the app's team.

```bash
# What the company trusts, and what to enter in Okta
curl -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/agent-access"

# Switch it off: every agent session it approved ends now
curl -X POST -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/agent-access/disable"
```

`GET /agent-access` answers:

```json
{
  "available": true,
  "audience": "https://<your server>/org/<org id>",
  "tokenEndpoint": "https://<your server>/api/v1/auth/oauth2/token?org=<org id>",
  "maxTokenMinutes": 60,
  "issuer": {
    "issuer": "https://globex.okta.com",
    "tenant": null,
    "jwksUri": "https://globex.okta.com/oauth2/v1/keys",
    "status": "active",
    "advertisesIdJag": true,
    "keys": {
      "count": 2,
      "checkedAt": "2026-10-18T09:12:44.000Z",
      "error": null
    },
    "tokenMinutes": 30,
    "addedAt": "2026-10-18T09:12:44.000Z",
    "addedBy": "Priya Shah",
    "lastUsedAt": "2026-10-18T10:02:13.000Z"
  },
  "agents": {
    "mode": "approved",
    "allowed": [
      {
        "clientId": "<client id>",
        "title": "Globex assistant",
        "identifiedBy": "registered"
      }
    ]
  },
  "provider": "okta"
}
```

| Field             | What it is                                                                                                                                                                                                                       |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `available`       | Whether the app's team has turned it on. While `false`, nothing here takes effect                                                                                                                                                |
| `audience`        | The app's issuer, which every agent grant must be for: Okta's **Issuer URL**                                                                                                                                                     |
| `tokenEndpoint`   | Where agents redeem the grant                                                                                                                                                                                                    |
| `maxTokenMinutes` | The longest token the app's team allows                                                                                                                                                                                          |
| `issuer`          | The identity provider the company trusts, or `null`: its `status` (`active` or `disabled`), keys found and when, the token lifetime, who added it ("The app's team" when the app's team did), and when an agent last got a token |
| `agents`          | The company's connected-apps policy (`mode`: `any`, `approved` or `none`) and the agents it allows, each with the client ID Okta asks for; `identifiedBy` is `registered` or `metadata_document`                                 |
| `provider`        | `okta`, `entra`, `google` or `other`, from the company's single sign-on connection, or `null`                                                                                                                                    |

`issuer.status` is `pending` until the identity provider is proven the company's own by a first sign-in, or approved by the app's team ([proven by first sign-in](/authentication/enterprise-agents#proven-by-first-sign-in)), then `active` or `disabled`. `POST /agent-access/disable` answers the same view with `issuer.status: "disabled"` and `sessionsEnded`, the live agent sessions it ended; switching off what is already off ends none. It is 404 `agent_issuer_not_found` when the company trusts no identity provider. Like signing out, it is not refused while the app's team keeps the company's settings. It is in the trail as **Agents through the identity provider changed**, `change: "disabled"`, with `via: "api"` and the key's name, and fires `enterprise.agent_issuer_updated`. Agent grants and refusals are in the trail as `enterprise_agent_access_granted` and `enterprise_agent_access_refused`, readable with `audit:read`, and `GET /insights` counts them under `agents`.

## Stricter rules for one workspace

A key with `policy:write` sets the same rules the company's admins set on the Single sign-on screen, for any of the company's bound workspaces, held to the same rule: as strict as the company's policy or stricter, field by field. A key is never asked to confirm it's you and is never held to the lockout guard, as for the company's own policy. While the app's team manages the company's policy, a key reads the rules and changes nothing (403 `managed_by_app`).

```bash
# Hold Globex finance to 15 minutes away, 8 hours, the office and passkeys only
curl -X PUT -H "Authorization: Bearer $COMPANY_KEY" -H "Content-Type: application/json" \
  -d '{"idleMinutes":15,"maxSessionHours":8,"ipAllowlist":{"ranges":["10.20.5.0/24"]},"signInMethods":{"allowed":["passkey"]}}' \
  "https://<your server>/api/v1/company/policy/workspaces/<workspace id>"

# Back to the company's policy
curl -X DELETE -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/policy/workspaces/<workspace id>"
```

The body takes `idleMinutes`, `maxSessionHours`, `ipAllowlist` (`{ ranges }`), `signInMethods` (`{ allowed }`), `requireSso`, `connectedApps` (`{ mode: "approved" | "none", approved }`), `outsiders` (`{ mode: "limited" | "blocked", maxRole }`), `appTeamSessions: "refused"` and `expectedUpdatedAt`; `null` or absent leaves a rule to the company, and a save replaces the workspace's rules whole. A key reaches the company's settings, never a workspace's data, so a workspace's address list does not apply to it.

## Exports

Two exports answer for the trail, and a script can use either:

- **`GET /audit/export`**, the CSV: any range the app's audit retention keeps, at most 50,000 rows a request, answered in one call. Every app has it.
- **`POST /audit/exports`**, WorkOS's export, once the app's team has turned on [company audit logs](/authentication/enterprise-sso#company-audit-logs-in-workos): up to 31 days at a time, from the last three months or as long as WorkOS keeps the log, if less, as a link to WorkOS's CSV that works for ten minutes.

```bash
# The CSV, straight away
curl -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/audit/export?from=2026-07-01T00:00:00Z" -o trail.csv

# WorkOS's export: ask for it...
curl -X POST -H "Authorization: Bearer $COMPANY_KEY" -H "Content-Type: application/json" \
  -d '{"from":"2026-10-01T00:00:00Z","to":"2026-10-15T00:00:00Z"}' \
  "https://<your server>/api/v1/company/audit/exports"

# ...then read it until state is "ready", and fetch url within ten minutes
curl -H "Authorization: Bearer $COMPANY_KEY" \
  "https://<your server>/api/v1/company/audit/exports/<export id>"
```

`POST /audit/exports` takes `from`, `to` (ISO dates) and `actions` (the trail's action names), all optional; with no range it covers the last 31 days. It answers `{ id, state: "pending", rangeStart, rangeEnd }`, and `GET /audit/exports/<export id>` the same with `state` `pending`, `ready` with a `url`, or `error`, when you ask for the export again. Reading a ready export again gives a fresh `url`. An export id works for an hour, and only with a key of the company that asked for it. Both downloads are in the trail as `enterprise_audit_exported`, `via: "workos"` or `via: "csv"`.

## Keys stay with BuildBase

Company API keys are issued and checked by BuildBase, against its own copy, with no call to WorkOS. They do not appear in the app owner's WorkOS dashboard, and they keep working when WorkOS cannot be reached. Once the app's team turns on company audit logs, making and revoking a key reaches the company's audit log in WorkOS like every other trail entry, as `company.key_created` and `company.key_revoked`, and so its WorkOS log stream.

## Who a key acts as

A key is either **someone's own** or **a service account's**:

- Someone's own key acts as them, and the trail names them. Making one needs a [company role](/authentication/enterprise-groups-and-roles) that manages **API keys**, and it can carry only the scopes their roles reach. On every call it uses only the scopes their roles still reach, so a Security admin moved to Auditor keeps a key that reads and no longer writes. When they hold no company role at all, named or through a group, or are blocked, deactivated or deprovisioned, every key they own stops at once, recorded as `enterprise_key_revoked` with `reason: "owner_not_admin"`. A personal key therefore needs the company to have named a Company admin.
- A service account's key acts as nobody: the trail names the key and no person. It outlives whoever made it, and stops when the service account is deleted (`reason: "service_account_deleted"`). Made in the app, it carries only the scopes the admin making it reaches through their own company roles, as someone's own key does; your team in the console may give any scope.

Every change made with a key is in the company's trail with `via: "api"`, the `keyId` and the `keyName`, and the company sees it in full. Your console sees it too.

## Keys are never asked to confirm it's you

In the app, a company admin [confirms it is them](/authentication/enterprise-sso#confirming-it-s-you) before a change to the company's security settings, unless they proved it is them in the last 10 minutes in a way the company counts. The company API never asks, and never answers `reauth_required`: a key has no person behind it to sign in again. What keeps a key honest instead:

- Making a key or a service account in the app needs that confirmation, so a stolen admin session cannot mint one.
- Every act of a key is in the company's trail under the key's name.
- Keys expire, can be held to the addresses they work from, and are revoked as described below.

## Lifetime, addresses and limits

- A key lasts 30, 90 (the default) or 365 days, or does not expire. An expired key is refused with `key_expired`.
- A key may list the addresses it works from, as IPs or CIDR ranges, at most 20. The company's IP allowlist, when on, applies to every key as well. A call from anywhere else is refused with 403 `ip_not_allowed` and recorded in the trail, once an hour per key and address.
- While your team keeps the company's settings (**Let the company's admins change this in the app** off), keys still read, and every change is refused with 403 `managed_by_app`.
- 300 calls a minute per key. A company holds at most 50 working keys and 20 service accounts.

## Refusals

| Status | `code`                       | Why                                                                                                                                                                |
| ------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 400    | `tenant_header_invalid`      | The request carried `allowed-company-ids` twice, and one names a company of this app: the network's proxy adds the header instead of replacing it                  |
| 401    | `invalid_key`                | No key, or not a working company API key (malformed, unknown, or altered)                                                                                          |
| 401    | `key_revoked`                | Revoked by hand, its owner holds no company role, or its service account was deleted                                                                               |
| 401    | `key_expired`                | Past its expiry                                                                                                                                                    |
| 403    | `company_unavailable`        | Single sign-on is off, or the company was deactivated or deleted                                                                                                   |
| 403    | `ip_not_allowed`             | From an address the key, or the company's allowlist, does not list                                                                                                 |
| 403    | `tenant_restricted`          | On a network whose `allowed-company-ids` header names companies of this app, not this key's company                                                                |
| 403    | `scope_required`             | The key lacks the scope the route needs, or its owner's roles no longer reach it; `scope` names it                                                                 |
| 403    | `company_role_required`      | A group change that gives a company role (itself or through a group above), or publishing a group that carries one, with a key that lacks `admins:write`           |
| 403    | `managed_by_app`             | A change while your team keeps the company's settings                                                                                                              |
| 403    | `company_admin_console_only` | Publishing or unpublishing Company admin: only your team does that, from the console                                                                               |
| 404    | `not_found`                  | No such route, or no such person, request, app, stream or export in this company                                                                                   |
| 404    | `not_a_person`               | Signing out, reading or ending the sessions of, or resetting the passkeys of, someone who is not one of the company's people                                       |
| 404    | `session_not_found`          | A session ref that is not one of that person's live sessions, or a session that has already ended                                                                  |
| 404    | `agent_issuer_not_found`     | Switching agents through the identity provider off while the company trusts none                                                                                   |
| 409    | `already_decided`            | A request to join that someone already approved or declined                                                                                                        |
| 409    | `sign_in_method_not_offered` | A sign-in method on the list that the app's sign-in does not offer; the app's team turns it on, or the list leaves it out                                          |
| 409    | `not_configured`             | `GET /it-contacts` while the app's WorkOS account is not set up                                                                                                    |
| 409    | `audit_logs_off`             | A WorkOS export or the WorkOS log stream, while the app's team has company audit logs off                                                                          |
| 409    | `use_workos_log_streams`     | A new log stream while company audit logs are on: the app's team sets it up in WorkOS                                                                              |
| 409    | `workos_roles_off`           | `assignableInWorkOS` while the app's team has role assignment in WorkOS off for the company                                                                        |
| 409    | `role_slug_taken`            | WorkOS already holds a role under the slug; the app's team can take it over from the console                                                                       |
| 409    | `role_in_use`                | Unpublishing or deleting a role or group the IT admin's groups are still assigned to in WorkOS; `roles` names it. They remove the groups in the Admin Portal first |
| 409    | `group_cycle`                | `includes` naming a group that already includes this one; `path` names the groups, outer to inner                                                                  |
| 409    | `group_change_in_progress`   | Another change to the company's groups is being saved; try again in a moment                                                                                       |
| 422    | `export_window`              | A WorkOS export longer than 31 days, or older than three months or WorkOS's retention; `earliest` is the oldest `from` it takes. Use the CSV                       |
| 422    | `export_empty`               | A WorkOS export of a range WorkOS holds nothing in                                                                                                                 |
| 422    | `group_too_deep`             | `includes` that would make a chain of more than 3 groups; `chain` names it                                                                                         |
| 422    | `too_many_managers`          | A group that has 10 managers already                                                                                                                               |
| 429    |                              | More than 300 calls in a minute                                                                                                                                    |
| 429    | `rate_limited`               | `GET /it-contacts` while WorkOS is rate limiting the app's WorkOS key; `Retry-After: 60`                                                                           |
| 502    | WorkOS's code                | `GET /it-contacts` when WorkOS could not be read                                                                                                                   |

## Making and revoking keys

**On the Single sign-on screen**, whoever manages the company lists its keys and service accounts, makes a key (their own, or a service account's) with its name, scopes, lifetime and addresses, sees the secret once beside the company API's address, and revokes a key or deletes a service account.

**In the console**, the enterprise's detail has the same two sections. Your team makes keys only for a service account, never one of its own, and can revoke any key.

```bash
# Console routes: an org API key with the enterprises resource
curl -H "Authorization: Bearer $API_KEY" \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/keys"

curl -X DELETE -H "Authorization: Bearer $API_KEY" \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/keys/<key id>"

curl -X DELETE -H "Authorization: Bearer $API_KEY" \
  "https://<your server>/api/auth/enterprise/enterprises/<enterprise id>/service-accounts/<id>"
```

The answers to making a key are refused with 400 `invalid` (naming the `field`), 404 `not_found` (no such service account), 422 `too_many_keys`, and, for a personal key, 422 `admins_required` or 403 `not_company_admin` (their roles do not manage API keys). In the app, a key of either kind with a scope the maker's roles do not reach is refused with 400 `invalid` on `scopes`, naming it. A service account is refused with 409 `name_taken` or 422 `too_many_service_accounts`.

| Webhook event            | When                       | Payload                                                                                                                     |
| ------------------------ | -------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `enterprise.key_created` | A company API key was made | `enterpriseId`, `name`, `keyId`, `keyName`, `scopes`, `ownerType`                                                           |
| `enterprise.key_revoked` | A key stopped working      | `enterpriseId`, `name`, `keyId`, `keyName`, `ownerType`, `reason` (`revoked`, `owner_not_admin`, `service_account_deleted`) |
