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:
| 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) |
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) |
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) |
GET /people/<user id>/sessions | people:read | { sessions }: one person's live sessions, newest first (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) |
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: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) |
GET /it-contacts | admins:read | { contacts, max, readAt }: the addresses WorkOS emails about the company's connection, read only (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, with assignableInWorkOS and includes (the groups inside it) |
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; 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) |
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.
# 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
}
]
}| 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). 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"
}| 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), 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_revokedwithreason: "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_allowedand 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.
# 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) |