Skip to content
BuildBase

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 first, if you have not.

Before you start

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:

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:

{
  "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 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). 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:

{
  "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:

ScopeLets the key
audit:readRead the company's trail, export it as CSV, and, with company audit logs on, export it from WorkOS
people:readList 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:readRead the access policy
insights:readRead the identity insights
admins:readRead who holds which company role, the company's roles, how they stand in WorkOS, and its IT and security contacts
groups:readRead the company's groups, everyone in each (named, linked or through a group inside it), what each gives and who manages it
apps:readRead the apps and agents its people connected, and the identity provider that approves agents
streams:readRead its log streams and how each is doing, BuildBase's and WorkOS's
people:writeSign one person, or everyone, out, or end one session; approve or decline requests to join; claim accounts on its domains
passkeys:writeReset a person's passkeys: remove them for the app and sign the person out
policy:writeChange the access policy, its sign-in methods included
admins:writeGive and take company roles, except the identity provider's, make roles, and publish them to WorkOS
groups:writeMake, 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:writeDisconnect an app or agent, for one person or everyone, and switch agents through the identity provider off
streams:writeAdd, 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

RouteScopeAnswers
GET /anyThe 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 /auditaudit:read{ entries, nextCursor }, filtered by action, from, to, limit and cursor as in the console
GET /audit/exportaudit:readThe same as CSV, at most 50,000 rows a request
POST /audit/exports, GET /audit/exports/<export id>audit:readWorkOS's export, with company audit logs on (exports)
GET /peoplepeople: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)
POST /sign-outpeople: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/resetpasskeys:writeRemoves that person's passkeys for the app and signs them out; answers { removed, sessions } (sign-in methods and passkeys)
GET /people/<user id>/sessionspeople:read{ sessions }: one person's live sessions, newest first (one person's sessions)
DELETE /people/<user id>/sessions/<session ref>people:writeEnds that one session; answers { ended: 1 }
GET /join-requestspeople:read{ requests, pending, max }: waiting requests first, then the last decided
POST /join-requests/<request id>/approve, POST /join-requests/<request id>/declinepeople:write{ request }, and on approval how many workspaces they joined
GET /unclaimedpeople:read{ people, total, domains }: accounts on the company's domains that are not its people, 50 a page, searched with search
POST /claimpeople:write{ "userIds": [...] } (up to 100) or { "all": true } (up to 100 a call); answers { claimed, skipped, remaining }
GET /outsiderspeople:read{ outsiders, total }: members of its workspaces outside the company, with the workspace and role
GET /policy, PUT /policypolicy:read, policy:writeThe 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)
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)
PUT /policy/workspaces/<workspace id>, DELETE /policy/workspaces/<workspace id>policy:writeSets 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 /insightsinsights:readThe insights over days (7, 30 or 90)
GET /adminsadmins: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 /rolesadmins: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-rolesadmins:readThe 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)
GET /it-contactsadmins:read{ contacts, max, readAt }: the addresses WorkOS emails about the company's connection, read only (IT and security contacts)
GET /groupsgroups:read{ groups, workspaces, max }
POST /groups, PATCH and DELETE /groups/<group id>groups:writeAs in company groups, with assignableInWorkOS and includes (the groups inside it)
GET /groups/<group id>, GET /groups/<group id>/people?search=&cursor=groups:readOne group, with includes, includedIn, inherited, rolesReached, managers and peopleBy; and everyone in it, 50 a page, with how and through
POST /groups/<group id>/membersgroups: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; answers { group }
GET /connected-apps, GET /connected-apps/<client id>apps:read{ apps }, and one app's { grants }
POST /connected-apps/revokeapps:write{ "clientId": "...", "userId": "..." }, without userId for everyone; answers { people }
GET /agent-accessapps:readThe identity provider that approves agents for the company, and what to enter in it (agents through the identity provider)
POST /agent-access/disableapps:writeSwitches it off, ending every agent session it approved; answers the same with sessionsEnded
GET /log-streamsstreams:read{ streams, companyManages }
GET /log-streamstreams:readThe 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>/teststreams:writeAs 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.

# 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. 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.

# 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"
{
  "people": [
    {
      "userId": "...",
      "email": "[email protected]",
      "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.

# 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:

{
  "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
    }
  ]
}
FieldWhat it is
idThe session's ref, which DELETE takes. Never the session itself, which is a credential
deviceThe device's name, or null
browser, osRead from the user agent, such as Chrome 141 and iOS 18, or null. A version browsers freeze is left out: macOS, Windows
ip, placeWhere it was used from. Both are null for a session the app's team opened
signedInAtWhen it began
lastActiveAtWhen it was last used
endsAtWhen it ends: the earlier of its expiry and your access policy's maximum session length
methodHow 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
currentWhether 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). 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:

# 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:

{
  "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). GET /policy answers what to set, and what this very request carried, so a script run from the office checks the proxy:

{
  "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:

# 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"
{
  "contacts": [
    {
      "ref": "3f9a0c2e71b4d5a8",
      "email": "[email protected]",
      "addedAt": "2026-10-18T09:12:44.000Z"
    },
    {
      "ref": "b07d41e9c3a25f60",
      "email": "[email protected]",
      "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). 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). 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.

# 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:

{
  "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"
}
FieldWhat it is
availableWhether the app's team has turned it on. While false, nothing here takes effect
audienceThe app's issuer, which every agent grant must be for: Okta's Issuer URL
tokenEndpointWhere agents redeem the grant
maxTokenMinutesThe longest token the app's team allows
issuerThe 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
agentsThe 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
providerokta, 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), 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).

# 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: 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.
# 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 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 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

StatuscodeWhy
400tenant_header_invalidThe 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
401invalid_keyNo key, or not a working company API key (malformed, unknown, or altered)
401key_revokedRevoked by hand, its owner holds no company role, or its service account was deleted
401key_expiredPast its expiry
403company_unavailableSingle sign-on is off, or the company was deactivated or deleted
403ip_not_allowedFrom an address the key, or the company's allowlist, does not list
403tenant_restrictedOn a network whose allowed-company-ids header names companies of this app, not this key's company
403scope_requiredThe key lacks the scope the route needs, or its owner's roles no longer reach it; scope names it
403company_role_requiredA 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
403managed_by_appA change while your team keeps the company's settings
403company_admin_console_onlyPublishing or unpublishing Company admin: only your team does that, from the console
404not_foundNo such route, or no such person, request, app, stream or export in this company
404not_a_personSigning out, reading or ending the sessions of, or resetting the passkeys of, someone who is not one of the company's people
404session_not_foundA session ref that is not one of that person's live sessions, or a session that has already ended
404agent_issuer_not_foundSwitching agents through the identity provider off while the company trusts none
409already_decidedA request to join that someone already approved or declined
409sign_in_method_not_offeredA 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
409not_configuredGET /it-contacts while the app's WorkOS account is not set up
409audit_logs_offA WorkOS export or the WorkOS log stream, while the app's team has company audit logs off
409use_workos_log_streamsA new log stream while company audit logs are on: the app's team sets it up in WorkOS
409workos_roles_offassignableInWorkOS while the app's team has role assignment in WorkOS off for the company
409role_slug_takenWorkOS already holds a role under the slug; the app's team can take it over from the console
409role_in_useUnpublishing 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
409group_cycleincludes naming a group that already includes this one; path names the groups, outer to inner
409group_change_in_progressAnother change to the company's groups is being saved; try again in a moment
422export_windowA 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
422export_emptyA WorkOS export of a range WorkOS holds nothing in
422group_too_deepincludes that would make a chain of more than 3 groups; chain names it
422too_many_managersA group that has 10 managers already
429More than 300 calls in a minute
429rate_limitedGET /it-contacts while WorkOS is rate limiting the app's WorkOS key; Retry-After: 60
502WorkOS's codeGET /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.

# 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 eventWhenPayload
enterprise.key_createdA company API key was madeenterpriseId, name, keyId, keyName, scopes, ownerType
enterprise.key_revokedA key stopped workingenterpriseId, name, keyId, keyName, ownerType, reason (revoked, owner_not_admin, service_account_deleted)