# Typed client (/docs/frameworks/client)
> createIamClient gives browser and Node code a typed API inferred from your server instance, with a session store and passkey helpers.
`@better-iam/client` is the browser-safe way to call Better IAM. It depends on no server runtime. You import your
configured server instance **as a type**, so the client knows every API group, method, input, and result, while
database and authentication code stay out of the browser bundle. Every framework integration builds on
it, and the server-side helpers use the same client with an in-process transport.
Why not `fetch` the HTTP API yourself? You could, but you would repeat the wire format on every call: the route
for each method, the JSON envelope, the `X-Better-IAM` header the server requires, cookie credentials, and error
parsing. You would also lose the types, which are inferred from your own server instance, so a renamed method or a
wrong input fails at compile time instead of in production.
The client only makes calls. It does not read sessions inside your server's request handlers, guard routes, or
check where incoming requests come from; a [server integration](/docs/frameworks) does those.
| Export | Entry | What it does |
| ------------------------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createIamClient(options)` | `@better-iam/client` | Creates the typed client: `client.{group}.{method}(input)` for every API method, plus `authorize`, `authorizeMany`, `listAccessible`, and `$request` |
| `IamClientError` (alias `ClientError`) | `@better-iam/client` | The error every failed call throws, with the server's stable `code`, the HTTP `status`, and retry details |
| `createSessionStore(client, { initial? })` | `@better-iam/client/session` | A framework-agnostic store for the current session, which the React, Vue, and Svelte bindings share |
| `isUnauthenticated(error)` | `@better-iam/client/session` | True when an error means "no usable session" (401 or 403) rather than a transport failure |
| Passkey helpers | `@better-iam/client/passkeys` | The browser side of WebAuthn registration and sign-in, kept out of the main entry so importing the client does not bundle them |
The umbrella package exposes the same entries as `better-iam/client`, `better-iam/client/session`, and
`better-iam/client/passkeys`. The client also exports types: `ClientOptions` (the options below),
`ClientCallOptions` (per-call options), `IamClient` (the client's full type, for props and context),
`ClientApi` (the per-group method map), and `ClientTransport` (the `$request` escape hatch).
## Install [#install]
npm
pnpm
yarn
bun
```bash
npm i @better-iam/client
```
```bash
pnpm add @better-iam/client
```
```bash
yarn add @better-iam/client
```
```bash
bun add @better-iam/client
```
Or install the umbrella package, `better-iam`, and import from `better-iam/client`.
## Setup [#setup]
### Serve the IAM HTTP API [#serve-the-iam-http-api]
The client talks to the IAM handler mounted in your application, `/api/iam` by default. Each framework page
shows how to mount it: [Next.js](/docs/frameworks/nextjs), [Nuxt](/docs/frameworks/nuxt),
[SvelteKit](/docs/frameworks/sveltekit), [React Router](/docs/frameworks/react-router),
[NestJS](/docs/frameworks/nestjs), or [Express, Hono, and Fastify](/docs/frameworks/node). A plain Node server
can use `createServer(iam.nodeHandler)`.
### Create one client [#create-one-client]
Import the server instance with `import type`, so nothing from it reaches the bundle, and create the client once
for the application.
```ts title="lib/iam-client.ts"
import { createIamClient } from '@better-iam/client';
import type { iam } from './iam.js';
export const client = createIamClient({
baseURL: 'https://identity.example.com',
onUnauthenticated: () => router.push('/login'), // lapsed or revoked session, never a wrong password
retryRateLimited: true, // retry once after the server's Retry-After when it is short
requestId: true, // X-Request-Id on every call; IamClientError.requestId for support tickets
});
```
### Call the API [#call-the-api]
Each API method hangs off its group, and inputs and results are typed. A login screen might resolve the
organization, sign in, and then ask which buttons and projects to show:
```ts
const { tenantId } = await client.tenants.lookup({ slug: 'acme' }); // public alias discovery for login screens
await client.auth.signIn({ tenantId, email, password });
const current = await client.auth.getSession();
const { results } = await client.authorizeMany({
tenantId,
checks: [{ action: 'projects:manage', resource: { type: 'project', id } }],
});
const { resources } = await client.listAccessible({
tenantId,
action: 'projects:read',
type: 'project',
});
const accounts = await client.links.list(); // linked identities in other organizations, for an account switcher
```
## Calling the API [#calling-the-api]
The client mirrors `iam.api`: `client.{group}.{method}(input, options?)`. Server methods take a credential as
their first argument; the client drops it, because the browser's credential is the session cookie (or the
bearer `token` you configure). Everything else about a method's input and result is inferred from
`typeof iam`.
* **Top-level checks.** `client.authorize`, `client.authorizeMany`, and `client.listAccessible` take their input
directly, without the credential fields.
* **Methods without input.** `auth` methods that take no input, such as `getSession`, `signOut`, `listSessions`,
`revokeOtherSessions`, `listPasskeys`, `beginPasskeyRegistration`, `regenerateRecoveryCodes`, `disableMfa`,
`listTrustedDevices`, `revokeTrustedDevices`, and `mfaStatus`, take only the call options.
* **Per-call options.** The last argument is `{ signal?, headers? }`: an `AbortSignal` to cancel the call, and
headers merged over the configured ones.
* **Plugin routes.** `client.$request('plugins/my-plugin/action', input)` calls any path relative to the
IAM `basePath`.
Every request is `POST {baseURL}{basePath}/{group}/{method}` with the input as a JSON body,
`Content-Type: application/json`, and `X-Better-IAM: 1`. The last two are always set, because the server's CSRF
(cross-site request forgery) boundary requires them. JSON mutations must carry `X-Better-IAM: 1`, and cookie
requests must also come from an exact trusted `Origin`. A page on another site cannot add custom headers to a
plain form post, so it cannot make the browser call the API with your visitor's cookies.
Requests use `credentials: 'include'` (so the session cookie goes along), `redirect: 'error'`, and
`cache: 'no-store'`. The server answers `{ data }` or `{ error: { code, message } }`, and the client returns
`data` or throws. The [HTTP API guide](/docs/guides/authentication/http) covers the wire format, cookies, and
trusted origins.
### Sign-in flows [#sign-in-flows]
`auth.signIn` returns either a session (`'token' in result`) or an MFA challenge
(`{ mfaRequired: true, challenge, enrollmentRequired, emailCodeAvailable?, passkeyAvailable? }`). Finish the
challenge with a method it allows: an authenticator code, first-time enrollment, an emailed code
(`requestMfaCode`), or a [passkey](#passkeys).
```ts
const result = await client.auth.signIn({ tenantId, email, password });
if ('mfaRequired' in result) {
if (result.enrollmentRequired) {
// First sign-in that must enroll an authenticator.
const { uri } = await client.auth.beginMfa({ tenantId, challenge: result.challenge });
showQrCode(uri);
await client.auth.confirmMfa({ credential: { tenantId, challenge: result.challenge }, code });
} else {
await client.auth.verifyMfa({ tenantId, challenge: result.challenge, code });
}
}
```
During an authenticated session, enroll with `client.auth.beginMfa()` and `client.auth.confirmMfa({ code })`.
[MFA](/docs/guides/authentication/mfa) lists every second-factor path, and
[Sign-in methods](/docs/guides/authentication/sign-in-methods) covers passwordless and federated sign-in.
* **Invitations.** Member invitations are redeemed without a session:
`client.identities.acceptInvitation({ tenantId, token, name, password })` creates the account, applies the
invited roles, and signs the person in.
* **Browser-session cookies.** The session cookie normally lasts as long as the session. Send
`X-Better-IAM-Persistent: 0` on the call that issues a session, for example
`client.auth.signIn(input, { headers: { 'x-better-iam-persistent': '0' } })`, to get a cookie that ends when the
browser closes ("keep me signed in" left unticked).
* **Bearer sessions.** For service or bearer sessions, pass `token: () => currentToken`. The client sends it as
`Authorization: Bearer` and never writes tokens to browser storage.
## Options [#options]
`onUnauthenticated` deliberately ignores credential failures such as `INVALID_CREDENTIALS` and codes the server
also raises during sign-in or step-up (`MFA_REQUIRED`, `EMAIL_UNVERIFIED`, `IP_BLOCKED`, and others). The client
cannot tell those apart from a dead session, and a trip to the login page in the middle of a step-up would lose
the person's work. Handle them per call.
## Errors [#errors]
Every refusal from the server, and every problem the client detects itself, is an `IamClientError` (also exported
as `ClientError`). It preserves the server's stable code, so your UI can branch on it, and never exposes the raw
response body. Network failures and aborted calls reject with Fetch's own error (such as a `TypeError` or an
`AbortError`) unchanged.
| Field | Meaning |
| -------------- | ------------------------------------------------------------------------------------------------------------- |
| `code` | The server's error code (`INVALID_CREDENTIALS`, `ACCESS_DENIED`, `RATE_LIMITED`, ...), or a client code below |
| `message` | The server's message |
| `status` | The HTTP status; `0` for errors raised before a request was sent |
| `retryAfterMs` | For `RATE_LIMITED`: how long to wait, from the error body or the `Retry-After` header |
| `requestId` | The `X-Request-Id` the request carried, when `requestId` is enabled |
```ts
import { IamClientError } from '@better-iam/client';
try {
await client.auth.signIn({ tenantId, email, password });
} catch (error) {
if (error instanceof IamClientError && error.code === 'RATE_LIMITED')
showMessage(`Try again in ${Math.ceil((error.retryAfterMs ?? 0) / 1000)} seconds`);
else throw error;
}
```
The client raises its own codes for problems it detects:
* `INVALID_CONFIG`: a bad `baseURL`, `basePath`, or `requestId`, or no Fetch implementation.
* `INVALID_TOKEN`: a bearer `token` that is not 16 to 512 letters, digits, `_`, and `-`.
* `INVALID_PATH`: a route segment that is not a plain name.
* `INVALID_INPUT`: input that is not serializable JSON.
* `INVALID_RESPONSE`: a response body that is not the IAM envelope.
* `HTTP_ERROR`: a non-2xx response without an error envelope.
The [error reference](/docs/reference/errors) lists the server's codes.
## Session store [#session-store]
A UI needs one shared answer to "who is signed in?" that every component sees and that updates everywhere at once
after a sign-in, a sign-out, or an expired session. `@better-iam/client/session` holds that answer. The React, Vue,
and Svelte bindings subscribe to it, and it works with plain scripts or any other framework.
```ts title="session.ts"
import { createIamClient } from '@better-iam/client';
import { createSessionStore } from '@better-iam/client/session';
import type { iam } from './iam.js';
const client = createIamClient();
const store = createSessionStore(client); // pass { initial: session } or { initial: null } after server rendering
store.subscribe(() => {
const { status, session, error } = store.getSnapshot();
render(status === 'authenticated' ? session.identity.name : status);
});
await store.refresh();
```
| Member | Does |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `getSnapshot()` | `{ status, session, error, updatedAt }`; `status` is `loading`, `authenticated`, `unauthenticated`, or `error` |
| `subscribe(listener)` | Calls `listener` on every change; returns the unsubscribe function |
| `refresh()` | Reloads the session from the server; concurrent calls share one request |
| `signOut()` | Signs out on the server, then clears the local session even if the server call failed (the failure is still thrown to the caller) |
| `set(session)` | Replaces the local session, for example right after a sign-in response |
Without `initial`, the store starts as `loading`; `initial: null` means known signed-out. A refresh that fails
with a 401 or 403 moves to `unauthenticated` and keeps the server's error, so its code can be shown. Any other
failure is a transport error: the status becomes `error` and the last known session is kept.
`isUnauthenticated(error)` applies the same rule, and also recognizes the server's own errors when a store runs on
the server with an in-process client. The module exports the types `SessionClient`, `SessionOf`,
`SessionSnapshot`, `SessionStatus`, and `SessionStore`.
## Passkeys [#passkeys]
A passkey ceremony has two halves. The server issues a challenge (options) and later verifies the signed response;
the browser asks the authenticator (a phone, a security key, Touch ID, Windows Hello) to create or use a
credential in between. `@better-iam/client/passkeys` provides the browser half by re-exporting these helpers from
`@simplewebauthn/browser`:
| Helper | What it does |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `startRegistration({ optionsJSON })` | Runs the browser's "create a passkey" prompt with the options from `auth.beginPasskeyRegistration` and returns the response to send to `auth.finishPasskeyRegistration` |
| `startAuthentication({ optionsJSON, useBrowserAutofill? })` | Runs the "use a passkey" prompt with options from `beginPasskeyAuthentication` or `beginPasskeyMfa`. With `useBrowserAutofill`, the prompt waits in the username field's autofill list instead of opening a dialog (the page needs an input with `autocomplete="username webauthn"`) |
| `browserSupportsWebAuthn()` | Whether the browser supports passkeys at all; check it before showing passkey buttons |
| `browserSupportsWebAuthnAutofill()` | Whether passkeys can be offered through autofill (resolves to a boolean) |
| `platformAuthenticatorIsAvailable()` | Whether the device has a built-in authenticator, a good moment to suggest "add a passkey" |
| `WebAuthnAbortService` | Cancels a pending ceremony (`cancelCeremony()`), for example a waiting autofill request before a button-started one |
It also exports the `RegistrationResponseJSON`, `AuthenticationResponseJSON`,
`PublicKeyCredentialCreationOptionsJSON`, and `PublicKeyCredentialRequestOptionsJSON` types.
**Register:**
Registration needs a recently authenticated session.
```ts
import { startRegistration } from '@better-iam/client/passkeys';
const challenge = await client.auth.beginPasskeyRegistration();
const response = await startRegistration({ optionsJSON: challenge.options });
await client.auth.finishPasskeyRegistration({
challengeId: challenge.challengeId,
response,
name: 'Work laptop', // optional
});
```
**Sign in:**
Without an `email`, the options name no credential and the authenticator's discoverable passkey picks the
account, which is what browser autofill uses.
```ts
import { startAuthentication } from '@better-iam/client/passkeys';
const begun = await client.auth.beginPasskeyAuthentication({ tenantId }); // or { tenantId, email }
const response = await startAuthentication({
optionsJSON: begun.options,
useBrowserAutofill: true, // omit for a button-triggered ceremony
});
await client.auth.finishPasskeyAuthentication({
tenantId,
challengeId: begun.challengeId,
response,
});
```
**Second factor:**
A registered passkey can answer the MFA challenge of a password sign-in (`passkeyAvailable: true`).
```ts
import { startAuthentication } from '@better-iam/client/passkeys';
const begun = await client.auth.beginPasskeyMfa({ tenantId, challenge: result.challenge });
const response = await startAuthentication({ optionsJSON: begun.options });
await client.auth.finishPasskeyMfa({
tenantId,
challengeId: begun.challengeId,
response,
rememberDevice: true, // optional
});
```
[Passkeys](/docs/guides/authentication/passkeys) covers relying-party configuration, discovery, and passkey
management (`listPasskeys`, `renamePasskey`, `deletePasskey`).
> **The server decides.**
The server authenticates and authorizes every call the client makes. Permission results from `authorize`,
`authorizeMany`, and `listAccessible` are advisory: use them to decide what to show, and enforce on the server.
## Next steps [#next-steps]
- [React](/docs/frameworks/react): `IamProvider` and hooks on top of this client.
- [Vue](/docs/frameworks/vue): The Vue plugin and composables.
- [API reference](/docs/reference/api): Every group and method the client can call.
# Frameworks (/docs/frameworks)
> What each framework integration does for you, and how the typed client, React, Vue, Next.js, Nuxt, SvelteKit, React Router, NestJS, and Node ones compare.
Every framework integration wraps the same `betterIam()` instance. None of them has its own session format,
policy engine, or audit trail. They read the session the server issued, ask the server for decisions, and call
the server's HTTP handler in process when they need to sign someone in. A page guarded in Next.js, a NestJS
controller, and an Express route therefore enforce the same roles, policies, conditions, boundaries, and
relationships, and write the same audit events.
## Why use an integration [#why-use-an-integration]
You can always call the core directly: mount `iam.handler`, pass a request's headers as the credential to
`iam.api.*`, and call `iam.require` before a mutation. The integrations exist because every web framework needs
the same glue around those calls, and the glue is where mistakes creep in:
* **Getting the credential.** Each request carries a session cookie or a bearer token. The integration reads it
the way your framework exposes requests, and looks the session up once per request instead of once per call.
* **Answering refusals the framework's way.** A signed-out visitor on a page should be redirected to the login
page with a safe `?next=`. The same refusal on an API route should be a 401 JSON body, and in a form action it
should come back to the form. The integrations map every IAM refusal to the right shape.
* **Setting cookies from the server.** When a server-side form signs someone in, the new session cookie has to
land on the response with the attributes the server chose. The integrations run sign-in through the IAM HTTP
handler in process and copy its `Set-Cookie` headers through your framework's cookie API.
* **Cross-site protection.** Browsers attach cookies automatically, even to a form another site's page submits,
so a cookie-authenticated `POST` must be checked for its `Origin` (a CSRF check). Most server integrations run
the same rule the IAM handler uses; [Cross-site checks](#cross-site-checks) lists where each one does it.
* **Fast, consistent UI.** Permission checks for a page full of buttons are batched into one call. Sessions and
decisions loaded on the server are handed to the browser, so the first render needs no extra requests. It also
matches the server's HTML, so there is no hydration mismatch (a browser render that differs from the HTML).
## Pick an integration [#pick-an-integration]
Choose the package for the framework your application runs on. The browser packages (the typed client, React,
and Vue) decide what to render and pair with any server. The full-stack and server packages mount the API, read
sessions on the server, and enforce access.
How to read the columns:
* **Session on the server**: how server code reads the signed-in person.
* **Guards**: what enforces access, or in the browser, decides what to show.
* **Mutations**: where state changes are checked.
* **Server rendering**: how a session loaded on the server reaches the browser for hydration.
| Stack | Package (umbrella subpath) | Session on the server | Guards | Mutations | Browser | Server rendering |
| ---------------------- | -------------------------------------------------------------------- | ------------------------------ | --------------------------------------------- | ----------------------------------- | ------------------------------------- | --------------------------- |
| Any browser app | `@better-iam/client` (`better-iam/client`) | not applicable | not applicable | every API method | typed client, session store, passkeys | not applicable |
| React | `@better-iam/react` (`better-iam/react`) | not applicable | `Can`, `useAuthorize` (advisory) | through the client | `IamProvider`, hooks | `initialSession` |
| Vue | `@better-iam/vue` (`better-iam/vue`) | not applicable | `IamCan`, `useCan` (advisory) | through the client | plugin, composables | `createHydration` |
| Next.js | `@better-iam/next` (`better-iam/next`, `/edge`, `/client`) | `getSession`, `requireSession` | `page`, `route`, `apiRoute`, middleware | `action`, `authActions`, `client()` | `IamNextProvider` | `sessionForClient` |
| Nuxt | `@better-iam/nuxt` (not in the umbrella), `/h3` | `getIamSession(event)` | `definePageMeta({ iam })`, `requireIamAccess` | server routes | auto-imported composables, `IamCan` | automatic payload hydration |
| SvelteKit | `@better-iam/svelte` (`better-iam/svelte`, `/kit`) | `locals.iam.getSession()` | `protect` rules, `guard` | `action` returns `fail()` | Svelte stores | `sessionData`, `initial` |
| React Router | `@better-iam/react-router` (`better-iam/react-router`) | `helpers(args).getSession()` | `guard` | `action` returns `data()` | `@better-iam/react` | `sessionData` |
| NestJS | `@better-iam/nestjs` (`better-iam/nestjs`) | `@CurrentPrincipal()` | `IamGuard`, `@Authorize` | `IamService.require` | not applicable | not applicable |
| Express, Hono, Fastify | `@better-iam/middleware` (`better-iam/express`, `/hono`, `/fastify`) | `req.iam.getSession()` | `requireSession()`, `authorize()` | `req.iam.require()` | not applicable | not applicable |
The umbrella package `better-iam` installs everything and exposes each integration as a subpath. The scoped
packages (`@better-iam/next`, ...) are the same code for applications that install only what they use.
`@better-iam/nuxt` is the exception: install it directly, because it is a Nuxt module and is not part of the
umbrella. See [Installation](/docs/guides/installation) for the full import map.
## How they share one core [#how-they-share-one-core]
Browser code reaches the instance over HTTP, through the API your server mounts. Server code (guards, loaders,
actions) calls the same instance directly in the same process, with no network hop.
The server-side integrations are built from the same pieces. Not every integration has every piece; the notes
in each item say which ones do.
* **A mounted HTTP API.** The browser client needs somewhere to send its calls. The IAM handler serves
`POST {basePath}/{group}/{method}` (default `/api/iam`), plus `/health`, `/metrics`, and the OAuth and
OpenID Connect, SAML, and SCIM protocol mounts. Next.js exports it from a catch-all route handler, Nuxt
mounts it as a Nitro route, SvelteKit serves it from `handle`, React Router from a resource route, NestJS
mounts it as middleware, and the Node adapters answer it before your routes run.
* **Sessions from the request.** Server helpers read the session cookie (`__Host-better-iam.session` on HTTPS,
`better-iam.session` on loopback HTTP) or a bearer token, and memoize the lookup for the request. Missing,
expired, revoked, and step-up-required credentials read as signed out instead of throwing.
* **Server-side decisions.** Guards enforce with `iam.require` (NestJS records each rule with `iam.authorize`).
Batched advisory checks, such as Next.js `allowed()` and `req.iam.can()`, go through `authorizeMany` with at
most 50 checks per call and one call per tenant. The resource defaults to the tenant itself (`iam/{tenantId}`) and the
tenant to the session's.
* **An in-process client.** Next.js, SvelteKit, React Router, and the Node adapters give server code the full
typed client with `iam.handler` as its transport. It forwards the caller's cookies and forwarding headers, sets
`Origin` to the IAM origin, and writes the `Set-Cookie` headers the server returns through the framework's own
cookie API. That is what lets a plain `
}
>
You may edit the roadmap.
```
An async server component. It renders `children` when the current session may perform the action and
`fallback` otherwise. `tenantId` defaults to the session's tenant.
**allowed():**
```ts
const canInvite = await iamNext.allowed('iam:identities:create'); // tenant defaults to the session's
const canEdit = await iamNext.allowed('documents:write', { type: 'document', id }, { tenantId });
```
One boolean per call; signed-out requests get `false` without a call.
**can():**
```tsx
const access = await iamNext.can({
tenantId: session.session.tenantId,
checks: [
{ action: 'documents:read', resource: { type: 'document', id: 'roadmap' } },
{ action: 'documents:write', resource: { type: 'document', id: 'roadmap' } },
{ action: 'iam:identities:read' }, // the tenant itself
],
});
access['documents:write@document/roadmap']; // true or false
```
One `authorizeMany` call. The result is keyed `${action}@${type}/${id}`, and every key is `false` when the
request is not authenticated.
`allowed()` and `` batch per request. Checks made while a render is in flight wait for one macrotask,
so sibling server components reach their checks after their own awaits. Then they go out as a single
deduplicated `authorizeMany`: 50 checks per call, one queue per tenant. A page full of permission-dependent
buttons costs one round trip, and answers are reused for the rest of the render.
## Next steps [#next-steps]
- [Server actions and forms](/docs/frameworks/nextjs/server-actions): Guarded mutations with `useActionState`, and sign-in without client JavaScript.
- [Step-up](/docs/frameworks/nextjs/advanced#step-up): Require MFA or a recent sign-in for sensitive pages.
- [Policies](/docs/guides/authorization/policies): What `authorize` actually evaluates.
# Next.js (/docs/frameworks/nextjs)
> Set up @better-iam/next in an App Router project, mount the IAM API, and read the session in server components, layouts, and client components.
`@better-iam/next` integrates Better IAM with the Next.js 15 App Router (React 19). The App Router spreads a
request across server components, layouts, route handlers, server actions, and middleware, and each exposes the
request differently: `headers()` and `cookies()` in components, a `Request` in route handlers, form data in
actions, and an edge runtime without database access in middleware. Calling the core API directly would mean
building the credential, redirecting with a safe `?next=`, mapping refusals to status codes, and writing session
cookies by hand in each of those places. One `createIamNext(iam)` call does it once and gives your server code:
* **Sessions in server components**: `getSession()` and `requireSession()`, memoized per request.
* **Guards**: `page()` for pages and layouts, `route()` and `apiRoute()` for route handlers, `action()` for server
actions, and `require()` for one-off checks.
* **Rendering by permission**: `can()`, `allowed()`, and the `` server component, batched into one
`authorizeMany` per request.
* **A server-side typed client**: `client()` calls the IAM handler in process and writes the cookies it issues,
so server actions can sign people in and out without client JavaScript.
* **Cross-site checks**: `route()`, `apiRoute()`, and `pages.api()` refuse cookie-authenticated mutations sent by
another site's page ([details](/docs/frameworks/nextjs/guards#cross-site-requests)). Next checks server
actions itself.
* **Hydration**: `sessionForClient()` hands the session to `IamNextProvider`, so client components render the
signed-in person on the first pass, matching the server's HTML ([Client components](#client-components)).
* **Auth forms**: drop-in server actions and unstyled, accessible forms for sign-in, MFA, step-up, password
reset, sign-up, email verification, and invitations.
* **Organizations in the URL**, **edge middleware**, **step-up**, **service credentials**, **webhooks and
assertions**, **background delivery**, and the **Pages Router**.
The package has three entries:
| Entry | Use from | Contents |
| ------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@better-iam/next` | Server components, route handlers, server actions | `createIamNext` and everything in the edge entry |
| `@better-iam/next/edge` | Middleware, edge route handlers, any Web runtime | `createIamMiddleware`, `safeRedirectPath`, assertion and webhook verification. No Node, React, or database imports, so the edge bundle stays small |
| `@better-iam/next/client` | Client components (`'use client'`) | `IamNextProvider`, `useSignOut`, the auth forms, and the React hooks |
The umbrella package exposes the same entries as `better-iam/next`, `better-iam/next/edge`, and
`better-iam/next/client`.
#### Every export, by entry
**`@better-iam/next`** (also re-exports everything from the edge entry)
| Export | What it does |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `createIamNext(iam, options)` | Creates the `iamNext` helpers described on these pages |
| `checkStepUp(session, requirement, now?)` | Checks a session (or an `apiRoute` principal) against a step-up requirement; returns `null` or the failure |
| `isAuthenticationError(error)` | True for the errors `getSession` treats as "signed out" |
| `isNextControlError(error)` | True for Next's `redirect()`, `notFound()`, `forbidden()`, and `unauthorized()` throws, which must propagate |
| `parseSetCookie(header)` | Parses a `Set-Cookie` header into the arguments `cookies().set` takes |
| `createAuthActions`, `authHiddenFields`, `authFields`, `authStepFields` | The building blocks behind `iamNext.authActions()` and the forms, for custom form containers |
| `createBackground(resolveIam, options)` | The building block behind `iamNext.background`, for instances you manage yourself |
**`@better-iam/next/edge`**
| Export | What it does |
| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `createIamMiddleware(options)` | Edge middleware: redirects visitors without a session cookie and forwards the path ([Middleware](/docs/frameworks/nextjs/middleware)) |
| `safeRedirectPath(value, fallback?)` | Returns a same-origin path, or the fallback, so `?next=` cannot become an open redirect |
| `matchPath(pattern, pathname)` | The `*` / `**` glob matcher behind `publicPaths` |
| `sessionCookieName(secure)` | `__Host-better-iam.session` on HTTPS, `better-iam.session` otherwise |
| `pathnameHeader` | The name of the forwarded-path header, `x-better-iam-pathname` |
| `verifyAssertionToken(token, options)`, `withAssertion(options, handler)`, `AssertionError` | Verify assertions offline in another service ([Assertions](/docs/frameworks/nextjs/advanced#assertions-for-other-services)) |
| `createWebhookHandler(options)`, `verifyWebhook(input)` | Receive and verify your deployment's webhooks ([Webhooks](/docs/frameworks/nextjs/advanced#receiving-your-own-webhooks)) |
**`@better-iam/next/client`**
| Export | What it does |
| ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `IamNextProvider` | `IamProvider` that refreshes server components when the signed-in identity changes |
| `useRouterSync()` | The hook inside `IamNextProvider`, for a provider tree you assemble yourself |
| `useSignOut({ redirectTo? })` | Returns a function that signs out, navigates, and refreshes server components |
| `SignInForm`, `ReauthenticateForm`, `PasswordResetRequestForm`, `PasswordResetForm`, `SignUpForm`, `InvitationForm` and their `*View` twins | The auth forms ([Server actions and forms](/docs/frameworks/nextjs/server-actions#the-forms)) |
| `useSession`, `useAuthorize`, `useAccessible`, `useIamClient`, `Can` | Re-exported from [`@better-iam/react`](/docs/frameworks/react) |
## Install [#install]
npm
pnpm
yarn
bun
```bash
npm i @better-iam/next @better-iam/client @better-iam/server @better-iam/adapter-sqlite
```
```bash
pnpm add @better-iam/next @better-iam/client @better-iam/server @better-iam/adapter-sqlite
```
```bash
yarn add @better-iam/next @better-iam/client @better-iam/server @better-iam/adapter-sqlite
```
```bash
bun add @better-iam/next @better-iam/client @better-iam/server @better-iam/adapter-sqlite
```
Or install the umbrella package, `better-iam`, and import from its subpaths. See
[Installation](/docs/guides/installation) for other storage adapters.
## Application layout [#application-layout]
A complete App Router setup spreads over these files. The instance, the helpers, and the API route are the
minimum; the others add edge redirects, drop-in forms, and background delivery.
- `route.ts`
- `route.ts`
- `route.ts`
- `layout.tsx`
- `page.tsx`
- `page.tsx`
- `auth-actions.ts`
- `layout.tsx`
- `providers.tsx`
- `iam.ts`
- `iam-next.ts`
- `middleware.ts`
- `next.config.mjs`
| File | Holds | Runtime |
| -------------------------------- | ------------------------------ | ----------------------------------------------------- |
| `lib/iam.ts` | `betterIam({ ... })` | Node only (database, argon2) |
| `lib/iam-next.ts` | `createIamNext(iam, options)` | Server components, actions, route handlers |
| `app/api/iam/[...path]/route.ts` | `iamNext.handlers()` | The browser client, OAuth/SAML/SCIM mounts, `/health` |
| `middleware.ts` | `createIamMiddleware(...)` | Edge: cookie presence and path forwarding |
| `app/auth-actions.ts` | `iamNext.authActions()` | Sign-in, step-up, and reset server actions |
| `app/api/webhooks/iam/route.ts` | `createWebhookHandler(...)` | Optional: consume your own IAM events |
| `app/api/cron/route.ts` | `iamNext.background.cron(...)` | Optional: scheduled delivery and maintenance |
## Setup [#setup]
### Create the instance [#create-the-instance]
```ts title="lib/iam.ts"
import { betterIam } from '@better-iam/server';
import { sqliteAdapter } from '@better-iam/adapter-sqlite';
export const iam = betterIam({
database: sqliteAdapter({ filename: process.env.BETTER_IAM_DATABASE ?? '.data/iam.db' }),
secret: process.env.BETTER_IAM_SECRET!,
baseURL: process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:3000',
permissions: { actions: ['documents:read', 'documents:write'] },
});
```
`baseURL` matters here: it becomes `iam.endpoint.origin`, the origin the in-process client presents and the one
the server trusts for cookie requests.
### Create the Next.js helpers [#create-the-nextjs-helpers]
`createIamNext` wraps the instance in the helpers your pages, route handlers, and actions use. `loginPath` and
`stepUpPath` are where guards send people who must sign in or confirm who they are.
```ts title="lib/iam-next.ts"
import { createIamNext } from '@better-iam/next';
import { iam } from './iam';
export const iamNext = createIamNext(iam, {
loginPath: '/login',
stepUpPath: '/reauth',
interrupts: 'forbidden', // denials render app/forbidden.tsx
});
```
### Mount the IAM HTTP API [#mount-the-iam-http-api]
The browser client (including passkey ceremonies) and the OAuth, SAML, and SCIM protocol mounts reach the server
through this catch-all route.
```ts title="app/api/iam/[...path]/route.ts"
import { iamNext } from '@/lib/iam-next';
export const runtime = 'nodejs';
export const { GET, POST, OPTIONS } = iamNext.handlers();
```
GET serves the operational `/health` and `/metrics` endpoints; every API method is a POST.
### Keep native modules out of the bundle [#keep-native-modules-out-of-the-bundle]
The server and its native dependencies (`argon2`, `better-sqlite3`) must load as plain Node modules. List them in
`serverExternalPackages`, or load the instance lazily and pass a factory instead of the instance.
**serverExternalPackages:**
```js title="next.config.mjs"
/** @type {import('next').NextConfig} */
export default {
serverExternalPackages: [
'@better-iam/server',
'@better-iam/auth',
'@better-iam/adapter-sqlite',
'argon2',
'better-sqlite3',
],
experimental: { authInterrupts: true }, // only for interrupts: true or 'forbidden'
};
```
**Lazy factory:**
```ts title="lib/iam.ts"
import type { BetterIam } from '@better-iam/server';
import { createIamNext } from '@better-iam/next';
async function create(): Promise {
// Loaded at runtime from node_modules, never bundled.
const [{ betterIam }, { sqliteAdapter }] = await Promise.all([
import(/* webpackIgnore: true */ '@better-iam/server'),
import(/* webpackIgnore: true */ '@better-iam/adapter-sqlite'),
]);
const iam = betterIam({
database: sqliteAdapter({ filename: process.env.BETTER_IAM_DATABASE ?? '.data/iam.db' }),
secret: process.env.BETTER_IAM_SECRET!,
baseURL: process.env.BETTER_IAM_BASE_URL ?? 'http://localhost:3000',
});
await iam.initialize();
return iam;
}
// One instance per process: Next may evaluate this module once per server layer.
const holder = globalThis as unknown as { __iam?: Promise };
export function getIam(): Promise {
holder.__iam ??= create();
return holder.__iam;
}
export const iamNext = createIamNext(getIam, { loginPath: '/login' });
```
`serverExternalPackages` only applies to packages resolved from `node_modules`. In a monorepo, workspace
packages are symlinks outside it, so the example app and the console load the server with
`import(/* webpackIgnore: true */ ...)` instead.
### Protect routes at the edge [#protect-routes-at-the-edge]
Add [middleware](/docs/frameworks/nextjs/middleware) that redirects visitors without a session cookie and
forwards the requested path, so `?next=` fills itself.
```ts title="middleware.ts"
import { NextResponse } from 'next/server';
import { createIamMiddleware } from '@better-iam/next/edge';
export const middleware = createIamMiddleware({
loginPath: '/login',
publicPaths: ['/', '/pricing', '/docs/**', '/invite/*'],
next: (init) => NextResponse.next(init),
});
export const config = { matcher: ['/((?!_next|favicon.ico).*)'] };
```
## Reading the session [#reading-the-session]
```tsx title="app/dashboard/page.tsx"
import { iamNext } from '@/lib/iam-next';
export default async function Dashboard() {
const current = await iamNext.getSession();
if (!current) return Sign in;
return
Welcome, {current.identity.name}
;
}
```
`iamNext.getSession()` reads the request's cookies (through `headers()` and `cookies()`) and returns
`{ identity, session }`. It returns `null` whenever the server refuses the credential as a sign-in. That covers
missing, expired, or revoked sessions, sessions that must step up, unverified email addresses, inactive tenants,
and requests from a network the session or tenant does not allow. Any other failure is rethrown. `requireSession()` returns the session or redirects to the login
page with `?next=`; [Guards](/docs/frameworks/nextjs/guards) covers it with the other wrappers.
* **Per-request memoization.** The no-argument call goes through React `cache`, so a layout, its page, and nested
server components share one lookup per render. Calls that pass `headers` explicitly are not memoized. The
`cache` option replaces React's; pass `(fn) => fn` to disable it.
* **After a server action.** The helper merges `cookies()` into the headers it sends. Cookies a server action
just set, such as a new session after sign-in, therefore apply to the re-render Next performs in the same
response.
* **Direct API calls.** `iamNext.credential()` returns `{ headers }` for any `iam.api.*` call made as the
current visitor: `iam.api.identities.list(await iamNext.credential(), { tenantId })`.
* **No cross-request state.** The helpers never cache across requests. Memoization is scoped to one render, and
there is no module-level session state.
### Client components [#client-components]
Client components get the session from the server so their first render needs no fetch.
`iamNext.sessionForClient()` returns the session as plain JSON without the stored token hash, or `null`. Pass it
to `IamNextProvider`, which is `IamProvider` plus a `router.refresh()` whenever the signed-in identity changes in
the browser ([details](/docs/frameworks/nextjs/advanced#keeping-server-components-in-sync)).
**app/layout.tsx:**
```tsx title="app/layout.tsx"
import type { ReactNode } from 'react';
import { iamNext } from '@/lib/iam-next';
import { Providers } from './providers';
export default async function RootLayout({ children }: { children: ReactNode }) {
return (
{children}
);
}
```
**app/providers.tsx:**
```tsx title="app/providers.tsx"
'use client';
import type { ReactNode } from 'react';
import { createIamClient } from '@better-iam/client';
import { IamNextProvider } from '@better-iam/next/client';
import type { BetterIam } from '@better-iam/server';
// Never called during server rendering (the provider starts from initialSession), but it needs an origin.
const client = createIamClient({
baseURL: typeof window === 'undefined' ? 'http://localhost' : window.location.origin,
});
type Session = Awaited>;
export function Providers(props: { initialSession: Session | null; children: ReactNode }) {
return (
{props.children}
);
}
```
Inside the provider, the [React hooks](/docs/frameworks/react) (`useSession`, `useAuthorize`, `useAccessible`,
`useIamClient`, `Can`) work as usual; `@better-iam/next/client` re-exports them.
## Options [#options]
`createIamNext(iam, options)` takes these options. Most applications set `loginPath`, `stepUpPath`, and
`interrupts`; `headers`, `cookies`, `redirect`, and the other overrides exist for tests and custom setups.
`createIamNext` accepts the instance or a factory (`() => iam` or `() => Promise`). Any object with the
`IamLike` surface works; a custom one without `endpoint` passes `baseURL` and `basePath`.
## `iamNext` at a glance [#iamnext-at-a-glance]
Everything `createIamNext` returns, and where each member is explained:
| Member | What it does | Details |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| `getSession(headers?)` | The current `{ identity, session }`, or `null`; memoized per request | [Reading the session](#reading-the-session) |
| `requireSession({ ... })` | The session, or a redirect to the login page (or the step-up page) | [Guards](/docs/frameworks/nextjs/guards#lower-level-helpers) |
| `require({ tenantId, action, resource })` | Enforces one action before rendering | [Guards](/docs/frameworks/nextjs/guards#lower-level-helpers) |
| `page(render, spec)` | Wraps a page or layout: session, step-up, authorization, then render | [Guards](/docs/frameworks/nextjs/guards#pages-and-layouts) |
| `route(handler, spec)` | Wraps a route handler for user sessions; answers IAM failures as JSON | [Guards](/docs/frameworks/nextjs/guards#route-handlers) |
| `apiRoute(handler, spec)` | `route()` that also accepts API keys and assumed roles | [Service credentials](/docs/frameworks/nextjs/advanced#service-credentials) |
| `action(fn, spec)` | Wraps a server action; returns `{ ok, data }` or `{ ok: false, error }` | [Server actions](/docs/frameworks/nextjs/server-actions#guarded-mutations) |
| `can({ tenantId, checks })` | Several advisory decisions in one call, keyed `action@type/id` | [Rendering by permission](/docs/frameworks/nextjs/guards#rendering-by-permission) |
| `allowed(action, resource?)`, `` | One advisory decision, batched with every other check in the render | [Rendering by permission](/docs/frameworks/nextjs/guards#rendering-by-permission) |
| `client()` | The typed API, running in process, writing issued cookies through `cookies()` | [In-process client](/docs/frameworks/nextjs/server-actions#the-in-process-client) |
| `authActions(options)` | Drop-in server actions for sign-in, step-up, reset, sign-up, verification, and invitations | [Auth forms](/docs/frameworks/nextjs/server-actions#auth-forms) |
| `handlers()` | `GET`, `POST`, and `OPTIONS` for the `app/api/iam/[...path]` route | [Setup](#setup) |
| `sessionForClient()` | The session as plain JSON (no token hash) for `IamNextProvider` | [Client components](#client-components) |
| `requireTenantSession({ slug })`, `tenant(slug)` | Resolve an organization alias and require a session in it | [Organizations](/docs/frameworks/nextjs/organizations) |
| `assertion({ tenantId, audience })` | A short-lived signed assertion for a downstream service | [Assertions](/docs/frameworks/nextjs/advanced#assertions-for-other-services) |
| `credential()` | `{ headers }` for direct `iam.api.*` calls as the current visitor | [Reading the session](#reading-the-session) |
| `currentPath()` | The path the middleware forwarded for this request, when present and safe | [Middleware](/docs/frameworks/nextjs/middleware) |
| `clearSessionCookie()` | Expires the session cookie in the current response | [Server actions](/docs/frameworks/nextjs/server-actions#the-in-process-client) |
| `background` | Outbox and event dispatch: `dispatch()`, `schedule()`, and the `cron()` route | [Background work](/docs/frameworks/nextjs/advanced#background-work) |
| `pages` | Pages Router helpers: `handler`, `withSession`, `api`, `client`, `getSession` | [Pages Router](/docs/frameworks/nextjs/advanced#pages-router) |
## In this section [#in-this-section]
- [Guards](/docs/frameworks/nextjs/guards): `page`, `requireSession`, `require`, `route`, and rendering by permission.
- [Server actions and forms](/docs/frameworks/nextjs/server-actions): The in-process client, guarded actions, and the drop-in auth forms.
- [Organizations in the URL](/docs/frameworks/nextjs/organizations): `requireTenantSession` for `/[org]/...` routes.
- [Middleware](/docs/frameworks/nextjs/middleware): Edge redirects, public paths, and `?next=` forwarding.
- [Advanced](/docs/frameworks/nextjs/advanced): Step-up, service credentials, webhooks, assertions, background work, and the Pages Router.
The [example app](https://github.com/better-iam/better-iam/tree/main/examples/nextjs) (`examples/nextjs`) runs
all of it on port 3300 with seeded accounts.
# Middleware (/docs/frameworks/nextjs/middleware)
> Redirect signed-out visitors at the edge with createIamMiddleware, keep public paths open, and forward the requested path so ?next= fills itself.
`createIamMiddleware` from `@better-iam/next/edge` sends visitors without a session cookie to the login page
before a protected page starts rendering, and forwards the requested path to your server components. It is a
routing convenience, not authorization.
Why use it: a signed-out visitor is redirected at the edge, before any server component runs or queries anything.
Also, App Router server components are not given the full path of the request they render. Without the forwarded
path, a guard deep in a layout would not know where to send the person back after sign-in.
> **Middleware checks presence, not validity.**
Middleware runs on the edge and cannot open the database. It only checks that the session cookie is present:
`__Host-better-iam.session` on HTTPS and `better-iam.session` on loopback HTTP. Pages must still authenticate
with `requireSession`, `page`, or `requireTenantSession`. A stale cookie gets through the middleware, and the
page's guard then redirects.
## Setup [#setup]
```ts title="middleware.ts"
import { NextResponse } from 'next/server';
import { createIamMiddleware } from '@better-iam/next/edge';
export const middleware = createIamMiddleware({
loginPath: '/login',
publicPaths: ['/', '/pricing', '/docs/**', '/invite/*'],
signedInRedirect: '/dashboard',
next: (init) => NextResponse.next(init),
});
export const config = { matcher: ['/((?!_next|favicon.ico).*)'] };
```
Import middleware helpers from `@better-iam/next/edge`. That entry has no Node, React, or database imports, so the
edge bundle stays small. The main entry re-exports everything in it.
## What it does, per request [#what-it-does-per-request]
* **Public paths.** The default rule protects everything except the login path (and paths below it), anything
under `/api/iam`, and the `publicPaths` globs. `publicPaths` adds to those defaults; `protect` replaces the
whole rule.
* **Redirects.** A protected request without the cookie gets a 307 to `loginPath` with `?next=` set to the path
and query it asked for.
* **Path forwarding.** With `next`, every request that continues carries `x-better-iam-pathname`.
`requireSession`, `page`, and `requireTenantSession` use it as the default `?next=`, so a deep link survives
sign-in without passing `returnTo` by hand. `iamNext.currentPath()` reads it (when present and safe).
## Signed-in redirects and stale cookies [#signed-in-redirects-and-stale-cookies]
`signedInRedirect` sends visitors who carry a cookie away from a bare login page, for example to the dashboard. A
login URL with `?next=` always renders.
That rule is what keeps a stale cookie from looping. When a server guard rejects a request that still carries a
session cookie (revoked, expired, or from a reset database), it always attaches `?next=` (at least `/`). The
middleware sees `?next=`, lets the login page render, and the visitor signs in again instead of bouncing between
the guard and the middleware.
## Safe redirects [#safe-redirects]
`safeRedirectPath(value, fallback = '/')` returns `value` only when it is a same-origin path. It rejects absolute
URLs, protocol-relative `//host`, backslashes, control characters, values over 2048 characters, and dot segments
that normalize into `//host`. Run every `next` parameter through it before redirecting:
```ts
import { safeRedirectPath } from '@better-iam/next/edge';
redirect(safeRedirectPath(searchParams.get('next'), '/dashboard'));
```
The auth forms and guards already do this. The edge entry also exports `matchPath(pattern, pathname)`, the glob
matcher behind `publicPaths`, and `sessionCookieName(secure)`, which returns the cookie name the middleware looks
for.
## Next steps [#next-steps]
- [Guards](/docs/frameworks/nextjs/guards): The server-side checks that authenticate and authorize after the middleware lets a request through.
- [Organizations in the URL](/docs/frameworks/nextjs/organizations): `requireTenantSession`, which also uses the forwarded path for `?next=`.
# Organizations in the URL (/docs/frameworks/nextjs/organizations)
> Serve each organization under its own /[org] path with requireTenantSession, and send visitors from other organizations to the right sign-in.
Multi-tenant applications often put the organization in the path: `/acme/projects`, `/globex/settings`. The
path segment is the organization's alias (its slug), and every page below it should run in that organization's
tenant. Two things can go wrong without help: an unknown or deactivated alias renders a broken page instead of a
404, and a person signed in to Acme who follows a link to `/globex` sees Globex pages rendered around an Acme
session. `iamNext.requireTenantSession({ slug })` handles both: it resolves the alias and requires a session in
that tenant, sending everyone else to sign in to the right organization.
## The organization layout [#the-organization-layout]
Put it in `app/[org]/layout.tsx`, and every page below gets the resolved tenant.
```tsx title="app/[org]/layout.tsx"
import type { ReactNode } from 'react';
import Link from 'next/link';
import { iamNext } from '@/lib/iam-next';
import { signOut } from '../auth-actions';
export default async function OrgLayout(props: {
params: Promise<{ org: string }>;
children: ReactNode;
}) {
const { org } = await props.params;
// Unknown aliases 404; visitors signed in elsewhere go to /login?org=...&next=...
const { tenant, session } = await iamNext.requireTenantSession({ slug: org });
return (
<>
{tenant.name}
{session.identity.name}
{props.children}
>
);
}
```
`requireTenantSession({ slug, headers?, returnTo?, redirectTo? })` returns `{ tenant, session }`, where `tenant`
is `{ tenantId, name, type, slug }`.
* **Resolution.** The alias goes through [`tenants.lookup`](/docs/reference/api/tenants#lookup), which finds
active tenants with active ancestors only. Unknown or inactive aliases call `notFound()`.
* **Wrong organization.** A visitor who is signed out, or signed in to a different organization, is redirected to
`/login?org={slug}&next=...` (or `redirectTo`). The login page can pre-select the organization or offer the
account switcher.
* **Return path.** `?next=` is `returnTo`, or the path the [middleware](/docs/frameworks/nextjs/middleware)
forwarded, so a deep link survives sign-in.
To resolve an alias without requiring a session, for example on a public landing page, use
`iamNext.tenant(slug)`. It returns the same summary, or `null` when the alias does not exist or is inactive.
## Pages below the layout [#pages-below-the-layout]
Wrap each page with `iamNext.page` as well: Next.js keeps layouts across client-side navigations instead of
re-rendering them, and the page needs the session anyway. Below the layout, the session's tenant is the
organization's tenant, which is also the default tenant for `authorize`.
```tsx title="app/[org]/page.tsx"
import { iamNext } from '@/lib/iam-next';
export default iamNext.page(async (_props: { params: Promise<{ org: string }> }, { session }) => {
const tenantId = session.session.tenantId;
const document = { type: 'document', id: 'roadmap' };
const access = await iamNext.can({
tenantId,
checks: [
{ action: 'documents:read', resource: document },
{ action: 'documents:write', resource: document },
{ action: 'iam:identities:read' },
],
});
return (
Welcome, {session.identity.name}
Read-only.}>
You may edit the roadmap.
{JSON.stringify(access, null, 2)}
);
});
```
The layout's `requireTenantSession` and the page's `page()` share one session lookup, because the no-argument
session read is memoized per request.
## The login page [#the-login-page]
The redirect carries `?org=` and `?next=`. Pass both to the sign-in form: `org` fills (or replaces) the
organization field, and `next` is where a completed sign-in returns.
```tsx title="app/login/page.tsx"
import { SignInForm } from '@better-iam/next/client';
import { signIn } from '../auth-actions';
export default async function Login(props: { searchParams: Promise<{ next?: string; org?: string }> }) {
const { next, org } = await props.searchParams;
return ;
}
```
Other ways to find the organization at sign-in:
* **Email domain.** `iamNext.authActions({ discover: true })` finds the tenant from the verified domain of the
email (`domains.discover`) when the form carries no `tenantId` or `org`. See
[Enterprise onboarding](/docs/federation/enterprise-onboarding) for verified domains.
* **Your own rule.** `authActions({ resolveTenant: async (form) => ... })` picks the tenant from the submission.
* **Account switcher.** A person with linked identities in several organizations can list them with
[`links.list`](/docs/reference/api/links#list) and switch with [`links.switch`](/docs/reference/api/links#switch).
Identities in different tenants stay separate, even when linked; see
[Tenants and identities](/docs/guides/concepts/tenants-and-identities).
## Next steps [#next-steps]
- [Server actions and forms](/docs/frameworks/nextjs/server-actions): The `SignInForm` and `authActions` options the login page uses.
- [Middleware](/docs/frameworks/nextjs/middleware): Forward the requested path so deep links survive sign-in.
# Server actions and forms (/docs/frameworks/nextjs/server-actions)
> Call Better IAM from Next.js server actions with the in-process client, guard mutations with action(), and drop in sign-in, step-up, and reset forms.
Server actions are where Next.js lets you change cookies, so they are where people sign in, sign out, step up,
and mutate data. `@better-iam/next` gives you three layers:
* `iamNext.client()`: the full typed API, bound to the current request and running in process.
* `iamNext.action(fn, spec)`: a guard that turns IAM failures into a serializable result for `useActionState`.
* `iamNext.authActions()` and the forms in `@better-iam/next/client`: complete authentication flows that work
with JavaScript disabled.
## The in-process client [#the-in-process-client]
Why not call `iam.api.auth.signIn` directly from the action? It would verify the password and return a session
token, but nothing would reach the browser. The session cookie, its attributes (host prefix, `HttpOnly`,
`SameSite`, lifetime), and the remembered-device cookie are set by the IAM HTTP handler. `iamNext.client()` runs
each call through that handler, in process, and copies what it sets onto your response.
`iamNext.client()` returns `createIamClient` whose transport is `iam.handler` in the same process.
For each call it:
1. copies the caller's `cookie`, `authorization`, `user-agent`, `accept-language`, and forwarding headers
(`x-forwarded-for`, `x-forwarded-host`, `x-forwarded-proto`, `x-real-ip`);
2. sets `Origin` to the IAM origin (`iam.endpoint.origin`, from `baseURL`) and sends JSON with `X-Better-IAM: 1`,
which satisfies the server's CSRF boundary;
3. writes every `Set-Cookie` the handler returns (session, trusted device, sign-out clearing) through
`cookies().set`.
```ts title="app/account/actions.ts"
'use server';
import { iamNext } from '@/lib/iam-next';
// Any IAM call from an action: cookies the server issues are written with cookies().set.
export async function revokeOtherDevices() {
await iamNext.client().auth.revokeOtherSessions();
}
```
This makes sign-in, MFA, sign-out, password change, and trusted-device flows work as `
` without
client JavaScript. Rate limits, tenant authentication policies, session metadata (`clientInfo`), and audit events
behave exactly as they do for browser calls. Next checks the `Origin` of the incoming server action POST itself,
so the forwarded cookie cannot be replayed cross-site. Failures reject with an `IamClientError` carrying the
server's `code`.
> **Cookie writes need an action or a route handler.**
Next only allows cookie writes in server actions and route handlers. A server component can call read-only
methods, such as `iamNext.client().identities.list(...)`. A method that issues a cookie throws an error naming
the cookie ("could not update the ... cookie; call this method from a server action or route handler").
`iamNext.clearSessionCookie()` expires the session cookie in the current response. Sign-out calls it when the
server no longer knows the presented session, so a stale cookie does not linger.
## Guarded mutations [#guarded-mutations]
`iamNext.action(fn, spec)` requires a session, optionally a [step-up](/docs/frameworks/nextjs/advanced#step-up),
and optionally an action, then calls `fn(session, ...args)`. It returns an `ActionResult`:
`{ ok: true, data }` or `{ ok: false, error: { code, message } }`, so IAM failures reach the form instead of an
error boundary.
**actions.ts:**
```ts title="app/projects/[id]/actions.ts"
'use server';
import { revalidatePath } from 'next/cache';
import { iamNext } from '@/lib/iam-next';
export const rename = iamNext.action(
async (session, _previous: unknown, form: FormData) => {
const id = String(form.get('id'));
await db.projects.rename(id, String(form.get('name')));
revalidatePath(`/projects/${id}`);
return { id };
},
{
authorize: {
action: 'projects:write',
resource: ({ args: [, form] }) => ({ type: 'project', id: String(form.get('id')) }),
},
},
);
```
**form.tsx:**
```tsx title="app/projects/[id]/rename-form.tsx"
'use client';
import { useActionState } from 'react';
import { rename } from './actions';
export function RenameForm({ id }: { id: string }) {
const [state, formAction, pending] = useActionState(rename, null);
return (
{state?.ok === false && (
{state.error.code}: {state.error.message}
)}
);
}
```
* **Codes you will see.** `UNAUTHENTICATED` (no session), `MFA_REQUIRED`, `RECENT_AUTH_REQUIRED`, or
`IMPERSONATION_RESTRICTED` (step-up), `ACCESS_DENIED`, `RATE_LIMITED`, and validation codes.
* **Your own failures.** Throw an `IamError` (from `@better-iam/core`, or `better-iam/core`) inside `fn`, for
example `new IamError('INVALID_INPUT', 'Write something first', 400)`, to report it the same way.
* **Everything else propagates.** Other errors, even with a `code` field, and `redirect()` or other Next control
flow thrown by `fn` are rethrown unchanged.
* **Argument order.** Pass the handler first and the spec second, so TypeScript infers the arguments from the
handler's annotations and checks the `resource` callback against them. `authorize` callbacks receive
`{ session, args }`.
Server actions never redirect or interrupt on an IAM failure, and they need no `Origin` check of their own.
## Auth forms [#auth-forms]
Signing in is rarely one request. A password may lead to an authenticator code, an emailed code, a recovery code,
or first-time enrollment. A reset needs an email step and a link step, and an invitation may require MFA before
the first session. Writing those flows as server actions means carrying the pending challenge between steps without
leaking secrets, redirecting only to safe paths, and keeping every step usable without JavaScript.
`iamNext.authActions(options)` returns server actions that do this, and `@better-iam/next/client` has a form for
each. Export the actions from a `'use server'` module and pass them to the forms.
### Export the actions [#export-the-actions]
```ts title="app/auth-actions.ts"
'use server';
import { iamNext } from '@/lib/iam-next';
const auth = iamNext.authActions({ afterSignIn: '/dashboard', discover: true });
export const signIn = auth.signIn;
export const reauthenticate = auth.reauthenticate;
export const requestPasswordReset = auth.requestPasswordReset;
export const resetPassword = auth.resetPassword;
export const signUp = auth.signUp;
export const verifyEmail = auth.verifyEmail;
export const acceptInvitation = auth.acceptInvitation;
export async function signOut() {
await auth.signOut();
}
```
Export each action as its own `const`: Next registers every export of a `'use server'` module as an action.
`signOut` is wrapped because it takes no state.
### Render the forms [#render-the-forms]
```tsx title="app/login/page.tsx"
import Link from 'next/link';
import { SignInForm } from '@better-iam/next/client';
import { signIn } from '../auth-actions';
export default async function Login(props: {
searchParams: Promise<{ next?: string; org?: string; reset?: string }>;
}) {
const { next, org, reset } = await props.searchParams;
return (
Sign in
{reset &&
Your password was changed. Sign in with the new one.
}
Forgot your password?
);
}
```
### Add the other pages [#add-the-other-pages]
**Forgot password:**
```tsx title="app/forgot/page.tsx"
import { PasswordResetRequestForm } from '@better-iam/next/client';
import { requestPasswordReset } from '../auth-actions';
export default function Forgot() {
return ;
}
```
**Reset link:**
```tsx title="app/reset/page.tsx"
import { PasswordResetForm } from '@better-iam/next/client';
import { resetPassword } from '../auth-actions';
// The page a password-reset email links to: /reset?tenantId=...&token=...
export default async function Reset(props: {
searchParams: Promise<{ tenantId?: string; token?: string }>;
}) {
const { tenantId, token } = await props.searchParams;
if (!tenantId || !token) return
This reset link is incomplete.
;
return ;
}
```
**Step-up:**
```tsx title="app/reauth/page.tsx"
import { ReauthenticateForm } from '@better-iam/next/client';
import { iamNext } from '@/lib/iam-next';
import { reauthenticate } from '../auth-actions';
// The page stepUp guards redirect to (stepUpPath), with ?next= and ?reason=.
export default async function Reauthenticate(props: {
searchParams: Promise<{ next?: string; reason?: string }>;
}) {
const { next } = await props.searchParams;
await iamNext.requireSession({ returnTo: '/reauth' });
return ;
}
```
**Sign out:**
```tsx title="app/sign-out-button.tsx"
import { signOut } from './auth-actions';
// A plain form: works in server components and without client JavaScript.
export function SignOutButton() {
return (
);
}
```
### The actions [#the-actions]
Each action moves through steps; the submit button the person presses sends an `intent` field that picks the
next one.
| Action | Steps and intents | Finishes with |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `signIn` | `password` or `send-code` then `code` (emailed sign-in code); then `mfa`, `email-code`, `recovery`, or `enroll` for the second factor; `cancel` starts over | A redirect to a safe `next` (or `afterSignIn`); after enrollment, the `done` step shows the recovery codes once |
| `reauthenticate` | `password`, then the same second-factor intents | A redirect to `next`; the replaced session is ended |
| `requestPasswordReset` | Email (and organization) | The `sent` step, with the same notice whether or not the account exists |
| `resetPassword` | `tenantId` and `token` from the email link, the new password, an optional confirmation | A redirect to `loginPath?reset=1` |
| `signUp` / `verifyEmail` | Self-registration (`authentication.signUpEnabled`) / the email link | `sent`, or a redirect to `loginPath?registered=1` / `?verified=1` |
| `acceptInvitation` | `kind` `member` or `owner`, `tenantId`, `token`, name, password, then enrollment when the tenant requires MFA | A redirect to `next` |
| `signOut` | None | Clears the cookie, also when the server no longer knows the session, and redirects to the login path |
Every action except `signOut` takes `(previousState, formData)` and returns an `AuthFormState`:
| Field | Meaning |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `step` | `credentials`, `mfa`, `enroll`, `code-sent`, `sent`, or `done` |
| `error` | `{ code, message, field?, retryAfterMs? }`: the server's code, the offending form field, and the wait for `RATE_LIMITED` |
| `mfa` | The pending challenge: `tenantId`, `challenge`, `enrollmentRequired`, `emailCodeAvailable`, `passkeyAvailable`, and while enrolling `enrollment: { secret, uri }` |
| `recoveryCodes` | Shown once after enrolling an authenticator |
| `notice` | A short status, for example "We sent a code to [ada@example.com](mailto:ada@example.com)" |
| `values` | Non-secret inputs to refill the form: `email`, `org`, `tenantId`, `name` |
| `next`, `keepSignedIn` | The safe return path and the "keep me signed in" choice, carried through later steps |
Passwords, codes, session tokens, and session records never enter the state. The MFA challenge travels in hidden
fields rather than server memory, which is what lets a form with JavaScript disabled post its second step. The
server still validates every challenge, so a forged field only ever fails. Every redirect goes through
`safeRedirectPath`.
* **Organization.** The actions find the tenant from a `tenantId` field, an `org` slug (`tenants.lookup`), your
`resolveTenant(form)`, or, with `discover: true`, the verified domain of the email (`domains.discover`).
* **Keeping a session.** "Keep me signed in" (`keepSignedIn`) sends `X-Better-IAM-Persistent`, so the cookie lasts
or ends with the browser. Step-up keeps a browser-session cookie as one unless the form opts in. "Remember this
device" (`rememberDevice`) on the authenticator step returns a device cookie that later sign-ins present
automatically.
* **Error text.** `messages` overrides the text for any error code or notice.
### The forms [#the-forms]
Each form renders the fields and steps of the action it is paired with, so a page only passes the action and a few
props:
| Form | Props besides `action` |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SignInForm` | `next`, `org`, `tenantId`, `email`, `showOrganization` (default true unless `tenantId` or `org` is given), `keepSignedIn` (offer the checkbox), `passwordless` (offer an emailed sign-in code) |
| `ReauthenticateForm` | `next`, `keepSignedIn` (whether the new cookie outlives the browser session; off by default) |
| `PasswordResetRequestForm` | `org`, `tenantId` |
| `PasswordResetForm` | `tenantId`, `token` |
| `SignUpForm` | `tenantId`, `next` |
| `InvitationForm` | `tenantId`, `token`, `kind` (`member` or `owner`), `next` |
Every form also takes `labels` (override any default English string) and `className`.
The forms render unstyled semantic HTML with hooks for your CSS: `
`
and `
`. Labels are bound, errors are tied to their field with `aria-invalid` and
`aria-describedby` and announced with `role="alert"`, notices use `role="status"`, and the first field of each
step takes focus. Alternative steps are named submit buttons (`name="intent"`), so a form works with JavaScript
disabled, and `useActionState` keeps it in place when JavaScript is on.
Each form has a `*View` twin (`SignInFormView`, `ReauthenticateFormView`, ...) that takes
`{ state, formAction, pending }` instead of `action`, for your own container or for tests.
> **Passkeys need the browser.**
Passkey sign-in and passkey MFA run the WebAuthn ceremony in the browser, so the server actions cannot do them.
Use [`better-iam/client/passkeys`](/docs/frameworks/client#passkeys) with the typed client for those.
## Next steps [#next-steps]
- [Organizations in the URL](/docs/frameworks/nextjs/organizations): Send people to the right organization's sign-in with `?org=`.
- [Step-up](/docs/frameworks/nextjs/advanced#step-up): The re-authentication page `ReauthenticateForm` belongs on.
- [MFA](/docs/guides/authentication/mfa): Every second-factor path the sign-in form can take.
# accessPaths (/docs/reference/api/access-paths)
> Access paths tell a denied person what they can do on their own to be allowed, such as stepping up to MFA or requesting a package.
Access paths tell a denied person what they can do on their own to be allowed, such as stepping up to MFA or
requesting a package. When your application refuses an action, `accessPaths.find` lists those options, so the error
page can offer a button instead of "contact your administrator". See the
[access paths guide](/docs/guides/governance/access-paths).
## How paths are verified [#how-paths-are-verified]
Each candidate path is applied inside a transaction that is always rolled back, and the ordinary authorization
decision is run again. A path is listed only when that decision then allows the action, so the list never promises
access that would still be refused:
* **`mfa`**: step up to multi-factor authentication, when the session has no MFA and an MFA session would be allowed.
* **`accept-agreements`**: accept the required [agreements](/docs/reference/api/agreements) the person still owes,
listed with the versions to accept.
* **`activate`**: activate one of the person's eligible
[just-in-time bindings](/docs/guides/privileged-access/elevation), held directly or through a group, with the
binding's activation settings (`requireApproval`, `requireJustification`, `requireMfa`, `maxActivationMs`). Listed
only when the person holds `iam:bindings:activate` on the role.
* **`request-package`**: request a requestable [access package](/docs/reference/api/packages#request). Listed only
when the person holds `iam:packages:request` on the package. The request still needs an approver's decision.
Each path is tested on its own, so when only a combination would help (MFA and an activation, for example) neither
is listed. At most 50 eligible bindings and 50 requestable packages are tried. An empty list means nothing the
person can do alone would help: they need an administrator.
| Method | What it does | Access |
| --------------- | -------------------------------------------------------------------------------------- | ---------- |
| [`find`](#find) | Lists what you could do yourself to be allowed an action on a resource you are denied. | Credential |
## find [#find]
Lists what you could do yourself to be allowed an action on a resource you are denied.
**HTTP:** `POST /api/iam/accessPaths/find` (requires a credential) · **Browser client:** `client.accessPaths.find()`
* **Permission:** None beyond your own ordinary session of the tenant (not an assumed role, another tenant's
session, or impersonation).
* **Audited as:** Not audited; nothing is saved.
* **Errors:** `ACCESS_DENIED` from a role session or another tenant's session; `IMPERSONATION_RESTRICTED` from an
impersonation session; `INVALID_ACTION` for an action the catalog does not know; `INVALID_INPUT` when the action or
resource is missing.
When you are already allowed, the result is `allowed: true` with the decision reason and no paths. When you are
denied, `reason` is always `ACCESS_DENIED`: like `authorize`, the call does not reveal which statement refused you.
Call it from the code path that handles a denial, with the same action and resource you just checked.
`useAccessPaths` wraps it for React and Vue apps.
```ts
const result = await iam.api.accessPaths.find(credential, {
tenantId,
action: 'documents:delete',
resource: { type: 'document', id: 'doc_42' },
});
for (const path of result.paths) {
if (path.kind === 'mfa') showStepUpButton();
if (path.kind === 'activate') showActivateButton(path.bindingId, path.role.name);
if (path.kind === 'request-package') showRequestButton(path.package.id, path.requireJustification);
if (path.kind === 'accept-agreements') showTermsDialog(path.agreements);
}
```
```ts title="Signature"
iam.api.accessPaths.find(
credential: CredentialInput,
input: { tenantId: string; action: string; resource: { type: string; id: string } },
): Promise
```
# accessRequests (/docs/reference/api/access-requests)
> Access requests let members ask for specific roles instead of asking an administrator to bind them by hand.
Access requests let members ask for specific roles instead of asking an administrator to bind them by hand. A
member names up to 20 roles with a justification and an optional duration; a reviewer approves or denies, and
approval creates the bindings under the reviewer's own grant authority, so a reviewer can never grant more than they
could bind directly.
## Request lifecycle [#request-lifecycle]
A request starts `pending` and ends in exactly one of `approved`, `denied`, `cancelled` (by the requester, or when
the requester is offboarded or deleted), or `expired`. Two times matter:
* **How long a request waits.** The request's `expiresAt` is when a pending request lapses: `accessRequests.lifetimeMs`
after it was made (seven days by default; a
[deployment option](/docs/operations/deployment/configuration) between one minute and 365 days). A lapsed request
is reported as `expired` at once and marked so in storage by the purge worker (`iam.purgeDeleted()`).
* **How long the access lasts.** `durationSeconds` (at least 60, at most `accessRequests.maxDurationSeconds`, 90
days by default) sets the end of the granted bindings, counted from approval. The request records it as
`grantExpiresAt`. Without a duration the bindings are permanent.
Approved bindings are ordinary role bindings tagged with `accessRequestId`. They end at `grantExpiresAt`, or when an
administrator removes them with [`bindings.delete`](/docs/reference/api/bindings#delete); the request itself only
records the decision. No notifications are sent: reviewers find work with `list({ status: 'pending' })`.
Use access requests for ad hoc roles. For a curated bundle of roles and groups with designated approvers, use
requestable [access packages](/docs/reference/api/packages#request) instead.
| Method | What it does | Access |
| ----------------------- | -------------------------------------------------------------------------------------------------------- | ---------- |
| [`approve`](#approve) | Approves a pending request, binding each requested role to the requester under your own grant authority. | Credential |
| [`cancel`](#cancel) | Withdraws one of your own pending requests. | Credential |
| [`create`](#create) | Asks for one or more roles for yourself, optionally for a limited time. | Credential |
| [`deny`](#deny) | Refuses a pending request, with an optional note for the requester. | Credential |
| [`get`](#get) | Returns one request. | Credential |
| [`list`](#list) | Lists the tenant's requests, newest first, optionally by status or requester. | Credential |
| [`listMine`](#listmine) | Lists your own requests, newest first, optionally by status. | Credential |
## approve [#approve]
Approves a pending request, binding each requested role to the requester under your own grant authority.
**HTTP:** `POST /api/iam/accessRequests/approve` (requires a credential) · **Browser client:** `client.accessRequests.approve()`
* **Permission:** `iam:access-requests:review` on the request, plus `iam:bindings:create` on each requested role
and a grant authority, exactly as [`bindings.create`](/docs/reference/api/bindings#create) requires.
* **Audited as:** `iam:access-requests:review`, plus `access-request:approve` with the requester, roles, binding IDs,
and `grantExpiresAt`.
* **Errors:** `INVALID_TRANSITION` (409) when the request is no longer pending, including when it has lapsed;
`ACCESS_DENIED` when you are the requester or cannot bind one of the roles; `INVALID_IDENTITY` when the requester
is not active; `NOT_FOUND` when the request, the requester, or a role is gone; `INVALID_INPUT` for a
`durationSeconds` out of range; `GRANT_AUTHORITY_REQUIRED` without a grant authority; `SOD_CONFLICT` when the roles
would create a [separation-of-duties](/docs/guides/authorization/separation-of-duties) conflict;
`INVARIANT_VIOLATION` when they would break an enforced invariant.
`durationSeconds` overrides the duration the requester asked for; the bindings end that long after approval. If the
requester already holds one of the roles through a binding under your authority, that binding is reused and its end
replaced by the approved one (removed, when no duration applies). The optional `note` is stored on the request.
```ts
await iam.api.accessRequests.approve(reviewerCredential, {
tenantId,
requestId,
durationSeconds: 8 * 60 * 60, // one working day instead of the week they asked for
note: 'Approved for the incident review.',
});
```
```ts title="Signature"
iam.api.accessRequests.approve(
credential: CredentialInput,
input: {
tenantId: string;
requestId: string;
durationSeconds?: number;
note?: string;
},
): Promise
```
## cancel [#cancel]
Withdraws one of your own pending requests.
**HTTP:** `POST /api/iam/accessRequests/cancel` (requires a credential) · **Browser client:** `client.accessRequests.cancel()`
* **Permission:** `iam:access-requests:create` on the request, and you must be the requester.
* **Audited as:** `iam:access-requests:create`.
* **Errors:** `ACCESS_DENIED` when the request is someone else's; `INVALID_TRANSITION` when it is no longer pending;
`NOT_FOUND` when it is not in this tenant.
```ts title="Signature"
iam.api.accessRequests.cancel(
credential: CredentialInput,
input: { tenantId: string; requestId: string },
): Promise<{
status: string;
reviewedAt: number;
requesterId: string;
roleIds: string[];
justification?: string;
durationSeconds?: number;
createdAt: number;
expiresAt: number;
reviewerId?: string;
note?: string;
bindingIds?: string[];
grantExpiresAt?: number;
id: string;
tenantId: string;
uniqueKey?: string;
}>
```
## create [#create]
Asks for one or more roles for yourself, optionally for a limited time.
**HTTP:** `POST /api/iam/accessRequests/create` (requires a credential) · **Browser client:** `client.accessRequests.create()`
* **Permission:** `iam:access-requests:create` on the tenant, from an ordinary session of that tenant.
* **Audited as:** `iam:access-requests:create`.
* **Errors:** `INVALID_INPUT` from a role session or another tenant's session, for zero or more than 20 roles, or a
`durationSeconds` out of range; `PROTECTED_RESOURCE` for an owner role; `NOT_FOUND` when a role is not in this
tenant; `CONFLICT` when a pending request for the same set of roles exists; `TOO_MANY_REQUESTS` (429) when you
already have 20 pending requests; `TENANT_INACTIVE` when the tenant is not active.
Nothing is granted until a reviewer approves. `justification` (up to 2048 characters) is shown to reviewers. Grant
`iam:access-requests:create` to every member, for example through a group everyone belongs to, and
`iam:access-requests:review` to the people who decide.
```ts
const request = await iam.api.accessRequests.create(memberCredential, {
tenantId,
roleIds: [supportAdminRole.id],
justification: 'Covering the support rotation this week',
durationSeconds: 7 * 24 * 60 * 60,
});
// request.status === 'pending'; request.expiresAt is when it lapses if nobody decides
```
```ts title="Signature"
iam.api.accessRequests.create(
credential: CredentialInput,
input: {
tenantId: string;
roleIds: string[];
justification?: string;
durationSeconds?: number;
},
): Promise
```
## deny [#deny]
Refuses a pending request, with an optional note for the requester.
**HTTP:** `POST /api/iam/accessRequests/deny` (requires a credential) · **Browser client:** `client.accessRequests.deny()`
* **Permission:** `iam:access-requests:review` on the request.
* **Audited as:** `iam:access-requests:review`, plus `access-request:deny` with the requester and roles.
* **Errors:** `INVALID_TRANSITION` when the request is no longer pending; `NOT_FOUND` when it is not in this tenant.
```ts title="Signature"
iam.api.accessRequests.deny(
credential: CredentialInput,
input: { tenantId: string; requestId: string; note?: string },
): Promise
```
## get [#get]
Returns one request.
**HTTP:** `POST /api/iam/accessRequests/get` (requires a credential) · **Browser client:** `client.accessRequests.get()`
* **Permission:** `iam:access-requests:read` on the request.
* **Audited as:** `iam:access-requests:read`.
* **Errors:** `NOT_FOUND` when the request is not in this tenant.
A pending request past its lifetime is returned as `expired`.
```ts title="Signature"
iam.api.accessRequests.get(
credential: CredentialInput,
input: { tenantId: string; requestId: string },
): Promise
```
## list [#list]
Lists the tenant's requests, newest first, optionally by status or requester.
**HTTP:** `POST /api/iam/accessRequests/list` (requires a credential) · **Browser client:** `client.accessRequests.list()`
* **Permission:** `iam:access-requests:read` on the tenant.
* **Audited as:** `iam:access-requests:read`.
* **Errors:** `INVALID_INPUT` for an unknown `status`.
The `status` filter matches the stored status, so until the purge worker runs, `status: 'pending'` can include
lapsed requests, which are reported as `expired`.
```ts title="Signature"
iam.api.accessRequests.list(
credential: CredentialInput,
input: { tenantId: string; status?: AccessRequestStatus; requesterId?: string },
): Promise
```
## listMine [#listmine]
Lists your own requests, newest first, optionally by status.
**HTTP:** `POST /api/iam/accessRequests/listMine` (requires a credential) · **Browser client:** `client.accessRequests.listMine()`
* **Permission:** `iam:access-requests:create` on the tenant, so anyone who may ask can see their own requests.
* **Audited as:** `iam:access-requests:create`.
* **Errors:** `INVALID_INPUT` for an unknown `status`.
```ts title="Signature"
iam.api.accessRequests.listMine(
credential: CredentialInput,
input: { tenantId: string; status?: AccessRequestStatus },
): Promise
```
# actions (/docs/reference/api/actions)
> Actions are the names that roles and policies allow or deny, such as iam:groups:update or documents:write.
Actions are the names that roles and policies allow or deny, such as `iam:groups:update` or `documents:write`. The
[permission catalog](/docs/guides/authorization/catalog) holds every action that exists: the built-in `iam:*`
actions, the actions your configuration and plugins declare, and, when the deployment sets
`permissions.mode: 'tenant-defined'`, actions a tenant registers itself. Stored policies may only name actions in the
catalog, so a typo fails with `INVALID_ACTION` instead of silently granting nothing. This group lists the catalog and
manages the tenant's own entries.
## Tenant-defined actions [#tenant-defined-actions]
A tenant action is always namespaced under one of the tenant's own
[resource types](/docs/reference/api/resource-types) as `{type}:{verb}`, for example `contract:approve` under a
`contract` type. Register the type first; `resourceTypes.register` can create its actions in the same call.
Registering an action grants nothing: it only makes the name available to roles and policies. Platform actions cannot
be registered, renamed, or removed through this group.
| Method | What it does | Access |
| --------------------------- | ------------------------------------------------------------------------------------------------- | ---------- |
| [`list`](#list) | Lists every action the tenant can use in policies: platform actions first, then the tenant's own. | Credential |
| [`register`](#register) | Adds a `{type}:{verb}` action under one of the tenant's resource types. | Credential |
| [`unregister`](#unregister) | Removes a tenant-defined action from the catalog. | Credential |
## list [#list]
Lists every action the tenant can use in policies: platform actions first, then the tenant's own.
**HTTP:** `POST /api/iam/actions/list` (requires a credential) · **Browser client:** `client.actions.list()`
* **Permission:** `iam:actions:read` on the tenant.
* **Audited as:** `iam:actions:read`.
Each entry has a `name`, a `source` of `platform` or `tenant`, and the `resourceType` it belongs to when it was
declared under one. Tenant actions also carry their `description`. Use it to populate policy and role editors.
```ts title="Signature"
iam.api.actions.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## register [#register]
Adds a `{type}:{verb}` action under one of the tenant's resource types.
**HTTP:** `POST /api/iam/actions/register` (requires a credential) · **Browser client:** `client.actions.register()`
* **Permission:** `iam:actions:create` on the tenant.
* **Audited as:** `iam:actions:create`.
* **Errors:** `CATALOG_LOCKED` (403) when the deployment does not allow tenant-defined actions; `INVALID_ACTION` when
the name is not `{type}:{verb}`, collides with a platform action or namespace, or its type is not a tenant-defined
resource type; `CONFLICT` when the action already exists.
The verb starts with a letter and uses letters, digits, `_`, or `-`. The description is optional, at most 512
characters.
```ts
await iam.api.actions.register(credential, {
tenantId,
name: 'contract:countersign',
description: 'Countersign a contract after legal review',
});
```
```ts title="Signature"
iam.api.actions.register(
credential: CredentialInput,
input: { tenantId: string; name: string; description?: string },
): Promise
```
## unregister [#unregister]
Removes a tenant-defined action from the catalog.
**HTTP:** `POST /api/iam/actions/unregister` (requires a credential) · **Browser client:** `client.actions.unregister()`
* **Permission:** `iam:actions:delete` on the tenant.
* **Audited as:** `iam:actions:delete`.
* **Errors:** `NOT_FOUND` when the tenant has no action by that name (platform actions included);
`RESOURCE_IN_USE` while a stored policy or inline role document names the action exactly.
Remove the action from every policy and role first; the check exists so no stored document is left naming an action
that no longer exists. Wildcard patterns such as `contract:*` do not count as references.
```ts title="Signature"
iam.api.actions.unregister(
credential: CredentialInput,
input: { tenantId: string; name: string },
): Promise<{ deleted: boolean }>
```
# agents (/docs/reference/api/agents)
> An agent is an AI agent registered as an account of its own: an identity of kind agent that a person answers for.
An agent is an AI agent registered as an account of its own: an identity of kind `agent` that a person answers for.
Like a service account it holds API keys ([`credentials.create`](/docs/reference/api/credentials#create)) and never
signs in, and roles, groups, and policies apply to it as to anyone. Two things set it apart: its credentials work only
while its sponsor is an active person of the same tenant, and its `boundary` policy caps everything it does, whatever
its roles say and whoever it acts for. People let an agent act on their behalf with
[delegations](/docs/reference/api/delegations). The repository guide is `docs/agents.md`.
## Sponsors and standing [#sponsors-and-standing]
Every agent has a sponsor (`agent.sponsorId`): an active, unexpired person of the agent's tenant, accountable for it.
`create` makes the caller the sponsor when the caller is a person in their own session of the tenant; otherwise name
one with `sponsorId`. An agent's **standing** says whether it may act right now:
* `ok`: it may act.
* `suspended`: the agent is disabled, as `suspend` does.
* `expired`: its `expiresAt` has passed.
* `deleted`: it was deleted.
* `sponsor-missing`: no person of the tenant matches `sponsorId`.
* `sponsor-inactive`: the sponsor is disabled, deleted, or past their own `expiresAt`.
Anything but `ok` refuses every credential of the agent with `UNAUTHENTICATED`: its API keys, its session tokens, and
the delegated sessions in which it acts for people. `credentials.create` refuses new keys meanwhile
(`INVALID_IDENTITY`). Offboarding a sponsor with a successor ([`identities.offboard`](/docs/reference/api/identities#offboard))
hands their agents to the successor, audited as `agent:sponsor-change`; without a successor the agents stay with the
leaver and are refused until an administrator names a new sponsor with `update`.
## Profile and ceiling [#profile-and-ceiling]
The profile fields `create` and `update` accept (in `update`, `null` clears an optional one):
| Field | Meaning |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `model`, `provider` | What the agent runs on. The model is 1 to 128 letters, digits, or `._:/@+-`; the provider 1 to 64 letters, digits, or `._-`, stored in lowercase. |
| `purpose`, `url` | Shown to people deciding whether to delegate to the agent: up to 1024 characters, and an http(s) URL. |
| `protocols` | An informational list such as `mcp` and `a2a`: at most 16 short lowercase names, deduplicated and sorted. |
| `boundary` | A policy document that caps everything the agent does, with its own keys and in delegated sessions. Changes apply to live sessions at once. |
| `delegable` | `false` refuses new delegations and the use of existing ones until it is turned back on. |
| `maxDelegatedSessionSeconds` | The longest delegated session the agent may open, 60 to 43200 seconds (3600 when unset). |
| `tokenAudiences` | Services outside Better IAM the agent may present a person's delegation to with a [delegation token](/docs/reference/api/delegations#issuetoken): at most 16 http(s) URLs or other absolute URIs (`urn:example:api`), without user info, query, or fragment. In a URL, `*` may start the host (`https://*.example.com`, any subdomain) or appear in the path (`https://api.example.com/v1/*`); a URL without a path matches only the root. Without it the agent gets no delegation tokens. |
Decisions for an agent's own key, and for sessions in which it acts for someone, carry `principal.agentId`,
`principal.agentSponsorId`, and, when set, `principal.agentModel` and `principal.agentProvider`; `principal.kind` is
`agent` for the agent's own key. See [principal keys](/docs/guides/authorization/conditions#principal-keys).
## Who may call what [#who-may-call-what]
Administrators use `iam:agents:create`, `iam:agents:read`, `iam:agents:update`, and `iam:agents:delete`, checked on
`iam/{agentId}` (on the tenant for `create` and `list`). A sponsor manages their own agents from their own signed-in
session of the tenant without any permission: `listMine`, `get`, `standing`, `activity`, `signCard`, `suspend`, and
`resume` of a suspension they made themselves. An agent may call `signCard` for itself with its own unscoped key. When
the caller is the agent's sponsor, these sponsor rules apply even if they also hold the administrative permission. Every person of the tenant may browse delegable agents with `catalog`. A "person's own
session" excludes API keys, role sessions, session tokens, delegated sessions, and impersonation.
| Method | What it does | Access |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`activity`](#activity) | Returns what an agent did, newest first: the audit events of its own credentials and of the sessions in which it acted for people. | Credential |
| [`catalog`](#catalog) | Lists the agents people of the tenant may delegate to, with what a person needs to decide. | Credential |
| [`create`](#create) | Registers an AI agent with a sponsor and an optional profile. | Credential |
| [`delete`](#delete) | Deletes an agent, ending its keys and sessions and revoking every delegation to it. | Credential |
| [`directory`](#directory) | Lists the tenant's agents that hold a current attested A2A card, for finding an agent to work with or hand work to. | Credential |
| [`get`](#get) | Returns one agent with its standing, its live API keys, and counts of its delegations. | Credential |
| [`list`](#list) | Lists the tenant's agents, newest first, optionally only one sponsor's or those in one standing. | Credential |
| [`listMine`](#listmine) | Returns the agents you sponsor, newest first, with the same detail as `get`. | Credential |
| [`resume`](#resume) | Lifts an agent's suspension, so its kept API keys work again. | Credential |
| [`signCard`](#signcard) | Signs an agent's A2A (Agent2Agent) agent card, so other agents can check that it is a registered agent in good standing of your organization. | Credential |
| [`standing`](#standing) | Tells whether an agent may act right now and, if not, why. | Credential |
| [`suspend`](#suspend) | Stops an agent at once (the kill switch) and keeps its API keys for `resume`. | Credential |
| [`suspendAll`](#suspendall) | The organization-wide emergency stop: suspends every active agent of the tenant at once. | Credential |
| [`update`](#update) | Changes an agent's name, description, expiry, attributes, profile, or sponsor. | Credential |
## activity [#activity]
Returns what an agent did, newest first: the audit events of its own credentials and of the sessions in which it acted for people.
**HTTP:** `POST /api/iam/agents/activity` (requires a credential) · **Browser client:** `client.agents.activity()`
* **Permission:** `iam:agents:read` on the agent, or none for its sponsor in their own session.
* **Audited as:** `iam:agents:read` for administrators; not audited for the sponsor.
* **Errors:** `NOT_FOUND` when the id is not an agent of this tenant; `INVALID_INPUT` for a `limit` outside 1 to 500 or
a malformed `offset`, `from`, or `to`.
An event belongs to the agent when the agent is its actor (its API keys and session tokens) or when its
`sessionContext.agentId` names the agent (a delegated session, where the actor is the person it acted for). Both
allowed and denied events are included, so a sponsor sees what the agent tried as well as what it did. Page with
`limit` (100 by default) and `offset`, and bound the time with `from` and `to` (epoch milliseconds).
```ts
const recent = await iam.api.agents.activity(sponsorSession, { tenantId, agentId, limit: 50 });
const refused = recent.filter((event) => event.outcome === 'deny');
```
```ts title="Signature"
iam.api.agents.activity(
credential: CredentialInput,
input: ActivityQuery & { tenantId: string; agentId: string },
): Promise
```
## catalog [#catalog]
Lists the agents people of the tenant may delegate to, with what a person needs to decide.
**HTTP:** `POST /api/iam/agents/catalog` (requires a credential) · **Browser client:** `client.agents.catalog()`
* **Permission:** None beyond a person's own session of the tenant.
* **Audited as:** Not audited; it only reads.
* **Errors:** `ACCESS_DENIED` for any other credential, including a session of another tenant.
Only active, delegable agents in good standing (`ok`) are listed, sorted by name. Each entry has `id` and `name`, the
`description`, `purpose`, `model`, `provider`, `url`, `protocols`, and `tokenAudiences` when set, and `sponsorName`, so
a consent screen can show who answers for the agent and which outside services it may carry a delegation to. Keys, the boundary, and sponsor ids are not included. Use it for a "connect an
agent" page that ends in [`delegations.grant`](/docs/reference/api/delegations#grant).
```ts title="Signature"
iam.api.agents.catalog(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## create [#create]
Registers an AI agent with a sponsor and an optional profile.
**HTTP:** `POST /api/iam/agents/create` (requires a credential) · **Browser client:** `client.agents.create()`
* **Permission:** `iam:agents:create` on the tenant.
* **Audited as:** `iam:agents:create`, plus `agent:create` with the sponsor, model, and provider.
* **Errors:** `INVALID_SPONSOR` when `sponsorId` is not an active, unexpired person of the tenant, or when it is left
out and the caller is not a person in their own session (an API key, for example); `LIMIT_EXCEEDED` (409) past the
tenant's `agents` limit; `INVALID_INPUT` for a malformed profile field, a `description` over 512 characters, or an
`expiresAt` that is not in the next ten years; `INVALID_POLICY` or `INVALID_ACTION` for a malformed `boundary`.
The agent starts `active` with no keys: issue them with `credentials.create` as for a service account, and give it
access with roles and bindings. `expiresAt` (epoch milliseconds) schedules its deactivation. Deleted agents do not
count toward the limit. The result carries the agent's `standing` and a `sponsor` summary (id, name, email, status).
```ts
const agent = await iam.api.agents.create(credential, {
tenantId,
name: 'Support triage',
purpose: 'Labels and routes incoming support tickets',
model: 'claude-sonnet-5',
provider: 'anthropic',
protocols: ['mcp'],
sponsorId: aliceId,
boundary: {
version: 1,
statements: [{ effect: 'allow', actions: ['tickets:*'], resources: ['ticket/*'] }],
},
});
const { token } = await iam.api.credentials.create(credential, {
tenantId,
identityId: agent.id,
name: 'production',
});
```
```ts title="Signature"
iam.api.agents.create(
credential: CredentialInput,
input: CreateAgentInput,
): Promise
```
## delete [#delete]
Deletes an agent, ending its keys and sessions and revoking every delegation to it.
**HTTP:** `POST /api/iam/agents/delete` (requires a credential) · **Browser client:** `client.agents.delete()`
* **Permission:** `iam:agents:delete` on the agent, and a recent sign-in.
* **Audited as:** `iam:agents:delete`, plus `identity:delete` with `kind: 'agent'` and `delegationsRevoked`.
* **Errors:** `RECENT_AUTH_REQUIRED` without a recent sign-in (temporary credentials never have one); `NOT_FOUND` when
the id is not an agent of this tenant; `CONFLICT` when it is already deleted.
Pending requests and active delegations involving the agent become `revoked`, and their delegated sessions end at once.
Like any deleted identity, the agent leaves a tombstone (status `deleted`) so audit records keep resolving it, and its
bindings, group memberships, and relationships are removed. The result is the tombstone. To stop an agent for a while
and keep its keys, use `suspend`.
```ts title="Signature"
iam.api.agents.delete(
credential: CredentialInput,
input: { tenantId: string; agentId: string },
): Promise
```
## directory [#directory]
Lists the tenant's agents that hold a current attested A2A card, for finding an agent to work with or hand work to.
**HTTP:** `POST /api/iam/agents/directory` (requires a credential) · **Browser client:** `client.agents.directory()`
* **Permission:** None beyond a credential of the tenant: a person's session, an agent's or service account's key, or
a delegated session.
* **Audited as:** Not audited; it only reads.
* **Errors:** `ACCESS_DENIED` for a credential of another tenant.
Every [`signCard`](#signcard) stores the agent's latest signed card as its directory entry. Entries are listed while
their attestation is valid and the agent is in good standing, sorted by name: `{ agentId, name, card, attestation,
expiresAt }`, where `card` is the signed card itself, ready for `verifyAgentCard` from `@better-iam/a2a`. `skill`
keeps agents whose card offers a skill with that id or tag, and `protocol` agents whose attestation lists that
protocol. Suspending or deleting an agent removes it; an expired entry is swept away.
```ts
const [translator] = await iam.api.agents.directory(credential, { tenantId, skill: 'translate' });
```
```ts title="Signature"
iam.api.agents.directory(
credential: CredentialInput,
input: { tenantId: string; skill?: string; protocol?: string },
): Promise
```
## get [#get]
Returns one agent with its standing, its live API keys, and counts of its delegations.
**HTTP:** `POST /api/iam/agents/get` (requires a credential) · **Browser client:** `client.agents.get()`
* **Permission:** `iam:agents:read` on the agent, or none for the agent's sponsor in their own session.
* **Audited as:** `iam:agents:read` for administrators; not audited for the sponsor.
* **Errors:** `NOT_FOUND` when the id is not an agent of this tenant.
`keys` lists unexpired API keys by label only (id, name, creation and expiry times, and `lastUsedAt` once used), never
token material. `delegations` counts the `active` and `pending` ones that have not lapsed, and
`liveDelegatedSessions` the unexpired delegated sessions. A deleted agent is still returned, with standing `deleted`.
```ts title="Signature"
iam.api.agents.get(
credential: CredentialInput,
input: { tenantId: string; agentId: string },
): Promise
```
## list [#list]
Lists the tenant's agents, newest first, optionally only one sponsor's or those in one standing.
**HTTP:** `POST /api/iam/agents/list` (requires a credential) · **Browser client:** `client.agents.list()`
* **Permission:** `iam:agents:read` on the tenant.
* **Audited as:** `iam:agents:read`.
Deleted agents are left out unless `includeDeleted: true`. `standing: 'sponsor-inactive'` finds the agents that
stopped because their sponsor left, the ones to hand to someone else with `update`.
```ts title="Signature"
iam.api.agents.list(
credential: CredentialInput,
input: {
tenantId: string;
sponsorId?: string;
standing?: AgentSummary['standing'];
includeDeleted?: boolean;
},
): Promise
```
## listMine [#listmine]
Returns the agents you sponsor, newest first, with the same detail as `get`.
**HTTP:** `POST /api/iam/agents/listMine` (requires a credential) · **Browser client:** `client.agents.listMine()`
* **Permission:** None beyond a person's own session of the tenant.
* **Audited as:** Not audited; it only reads.
* **Errors:** `ACCESS_DENIED` for any other credential, including a session of another tenant.
Deleted agents are left out. It backs a "my agents" page where sponsors see keys and delegations and reach the kill
switch.
```ts title="Signature"
iam.api.agents.listMine(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## resume [#resume]
Lifts an agent's suspension, so its kept API keys work again.
**HTTP:** `POST /api/iam/agents/resume` (requires a credential) · **Browser client:** `client.agents.resume()`
* **Permission:** `iam:agents:update` on the agent and a recent sign-in, or none for the sponsor when they made the
suspension themselves.
* **Audited as:** `agent:resume`; an administrator's call also as `iam:agents:update`.
* **Errors:** `ACCESS_DENIED` when the sponsor tries to lift someone else's suspension; `RECENT_AUTH_REQUIRED` for an
administrator without a recent sign-in; `CONFLICT` (409) when the agent is not suspended; `INVALID_TRANSITION` (409)
when it has expired (extend or clear `expiresAt` with `update` first); `NOT_FOUND` for a deleted agent.
The agent returns to `active` with its keys, roles, and delegations as they were. Delegated sessions and session
tokens ended by the suspension do not come back; the agent opens new ones. Its standing stays other than `ok` while its
sponsor is inactive.
```ts title="Signature"
iam.api.agents.resume(
credential: CredentialInput,
input: { tenantId: string; agentId: string },
): Promise
```
## signCard [#signcard]
Signs an agent's A2A (Agent2Agent) agent card, so other agents can check that it is a registered agent in good standing of your organization.
**HTTP:** `POST /api/iam/agents/signCard` (requires a credential) · **Browser client:** `client.agents.signCard()`
* **Permission:** None for the agent itself with its own unscoped API key (not a key with `scopes`, and not a session
token, role or delegated session); none for its sponsor in their own session; otherwise `iam:agents:update` on the
agent.
* **Audited as:** `agent:card-sign`, with the card's `url` and the attestation's expiry; an administrator's call also as
`iam:agents:update`.
* **Errors:** `FEATURE_DISABLED` (403) without the `a2a` option; `INVALID_INPUT` when the agent has no registered `url`,
when the card's `url`, an `additionalInterfaces[].url` or a `supportedInterfaces[].url` is not on that URL's origin,
or when the card is not a JSON object with a `name` of 1 to 256 characters (or is larger than 64 KiB);
`INVALID_IDENTITY` (409) when the agent is not in good standing; `NOT_FOUND` when the id is not an agent of this
tenant; `ACCESS_DENIED` for anyone else, including another agent's key or a scoped key of the agent itself.
Pass the card as `card`. IAM drops any `signatures` and any attestation already in the card and sets `provider` to the
tenant's name at the agent's registered origin, whatever the card said. It then adds the extension
`urn:better-iam:a2a:attestation:v1` to `capabilities.extensions`. Its params are `issuer`,
`tenantId`, `organization`, `agentId`, `agentName`, `sponsored: true`, `delegable`, `model`, `provider`, `protocols`,
and `issuedAt`/`expiresAt` (ISO 8601, `a2a.cardLifetimeSeconds` apart, one hour by default). IAM signs the RFC 8785
canonical form of the card (without `signatures`) as a detached JWS: `alg` EdDSA or ES256, the key's `kid`, `typ`
`JOSE`, and `jku` when `a2a.jwksUrl` is set. The result is `{ card, attestation, expiresAt }`. `card` carries one
entry in `signatures`, and `expiresAt` is in epoch milliseconds.
Verifiers check the card against the deployment's public card keys (`iam.a2a.jwks()`, served at `a2a.jwksUrl`) with
`verifyAgentCard` or `discoverAgent` from `@better-iam/a2a`. Suspending, deleting, or un-sponsoring the agent stops new
signatures at once, and signed cards stop verifying when their attestation expires. An agent's A2A server usually
re-signs its own card with `createCardAttestor`.
```ts
const { card } = await iam.api.agents.signCard(
{ token: agentKey },
{ tenantId, agentId, card: { name: 'Triage', url: 'https://triage.acme.test/a2a', skills: [] } },
);
```
```ts title="Signature"
iam.api.agents.signCard(
credential: CredentialInput,
input: { tenantId: string; agentId: string; card: Record },
): Promise
```
## standing [#standing]
Tells whether an agent may act right now and, if not, why.
**HTTP:** `POST /api/iam/agents/standing` (requires a credential) · **Browser client:** `client.agents.standing()`
* **Permission:** `iam:agents:read` on the agent, or none for its sponsor in their own session.
* **Audited as:** `iam:agents:read` for administrators; not audited for the sponsor.
* **Errors:** `NOT_FOUND` when the id is not an agent of this tenant.
The result is `{ agentId, standing }` with one of the values under [Sponsors and standing](#sponsors-and-standing), a
lighter call than `get` when only the answer matters.
```ts title="Signature"
iam.api.agents.standing(
credential: CredentialInput,
input: { tenantId: string; agentId: string },
): Promise<{ agentId: string; standing: AgentStanding }>
```
## suspend [#suspend]
Stops an agent at once (the kill switch) and keeps its API keys for `resume`.
**HTTP:** `POST /api/iam/agents/suspend` (requires a credential) · **Browser client:** `client.agents.suspend()`
* **Permission:** `iam:agents:update` on the agent, or none for its sponsor in their own session.
* **Audited as:** `agent:suspend`, with the `reason` and how many delegated sessions and session tokens ended; an
administrator's call also as `iam:agents:update`.
* **Errors:** `CONFLICT` (409) when it is already suspended; `NOT_FOUND` for a deleted agent or an id that is not an
agent of this tenant; `ACCESS_DENIED` for anyone else.
The agent becomes `disabled`, and `agent.suspended` records who suspended it, when, and why (`reason`, up to 512
characters). Its API keys are refused while it is suspended, and its live delegated sessions and session tokens are
deleted now, so an agent working for people stops mid-task. Delegations stay in place for after `resume`. No recent
sign-in is needed, so a sponsor can react immediately.
```ts
await iam.api.agents.suspend(aliceSession, { tenantId, agentId, reason: 'Looping on the wiki' });
```
```ts title="Signature"
iam.api.agents.suspend(
credential: CredentialInput,
input: { tenantId: string; agentId: string; reason?: string },
): Promise
```
## suspendAll [#suspendall]
The organization-wide emergency stop: suspends every active agent of the tenant at once.
**HTTP:** `POST /api/iam/agents/suspendAll` (requires a credential) · **Browser client:** `client.agents.suspendAll()`
* **Permission:** `iam:agents:update` on the tenant; no recent sign-in, so it works during an incident.
* **Audited as:** `agent:suspend-all` with the `reason`, the filters, and how many agents stopped; `agent:suspend` for
each agent; and `iam:agents:update`.
* **Errors:** `INVALID_INPUT` without a `reason` (up to 512 characters); `ACCESS_DENIED` without the permission.
Each active agent is suspended exactly as [`suspend`](#suspend) does: its credentials are refused and its live
delegated sessions and session tokens end now. `sponsorId`, `provider`, and `model` narrow the stop to one sponsor's
agents or those running on one provider or model. Agents already suspended are left as they are. The result is
`{ suspended, agentIds }`; agents come back one at a time with [`resume`](#resume).
```ts
await iam.api.agents.suspendAll(admin, { tenantId, reason: 'Prompt injection incident' });
```
```ts title="Signature"
iam.api.agents.suspendAll(
credential: CredentialInput,
input: {
tenantId: string;
reason: string;
sponsorId?: string;
provider?: string;
model?: string;
},
): Promise<{ suspended: number; agentIds: string[] }>
```
## update [#update]
Changes an agent's name, description, expiry, attributes, profile, or sponsor.
**HTTP:** `POST /api/iam/agents/update` (requires a credential) · **Browser client:** `client.agents.update()`
* **Permission:** `iam:agents:update` on the agent.
* **Audited as:** `iam:agents:update`, plus `agent:sponsor-change` (with `from` and `to`) when the sponsor changes.
* **Errors:** `INVALID_SPONSOR` when the new `sponsorId` is not an active person of the tenant; `NOT_FOUND` for a
deleted agent or an id that is not an agent of this tenant; `INVALID_INPUT`, `INVALID_POLICY`, or `INVALID_ACTION`
as for `create`.
Fields you leave out keep their values; `null` clears `description`, `expiresAt`, and the optional profile fields, and
`attributes` replaces the whole set. A new `boundary` and `delegable: false` apply to live sessions at once, because
both are read on every decision. Naming a new sponsor is how you bring back an agent whose sponsor left.
```ts
// The sponsor left without a successor: hand the agent to someone else.
await iam.api.agents.update(credential, { tenantId, agentId, sponsorId: bobId });
```
```ts title="Signature"
iam.api.agents.update(
credential: CredentialInput,
input: UpdateAgentInput,
): Promise
```
# agreements (/docs/reference/api/agreements)
> Agreements are versioned terms of use that a tenant asks its members to accept: an acceptable-use policy, an NDA, data-handling rules.
Agreements are versioned terms of use that a tenant asks its members to accept: an acceptable-use policy, an NDA,
data-handling rules. Better IAM records who accepted which version and when, and exposes the result to policies, so
you can hold back access until people accept. See the [terms of use guide](/docs/guides/governance/agreements).
## Versions, lapses, and enforcement [#versions-lapses-and-enforcement]
An agreement starts at version 1. Editing it with `newVersion: true` publishes the next version, and everyone must
accept again; an edit without it (a typo fix, a new link) keeps existing acceptances valid. An acceptance counts only
while it is for the current version and, when the agreement sets `reacceptAfterDays`, is younger than that many days
(annual re-acceptance, for example). Each person has one acceptance record per agreement, replaced each time they
accept.
Enforcement is an ordinary policy decision. Every evaluation for a person in their own tenant carries two
[condition](/docs/guides/authorization/conditions) keys:
* `principal.agreements`: the names of the agreements the person has accepted in their current version.
* `principal.pendingAgreements`: how many `required` agreements they still owe. Service accounts cannot accept
anything, so nothing is pending for them.
A deny statement on the count holds back access until every required agreement is accepted:
```json
{
"effect": "deny",
"actions": ["documents:*"],
"resources": ["*"],
"conditions": { "NumericGreaterThan": { "principal.pendingAgreements": 0 } }
}
```
`{ "ArrayContains": { "principal.agreements": ["Beta program"] } }` grants something only to people who accepted an
optional agreement. Sessions of an assumed role carry neither key, so conditions on them do not match there.
[`accessPaths.find`](/docs/reference/api/access-paths#find) tells a denied person when accepting their pending
agreements would let them in.
| Method | What it does | Access |
| ----------------------- | ------------------------------------------------------------------------------------------------------ | ---------- |
| [`accept`](#accept) | Records that you accept the given version of an agreement. | Credential |
| [`create`](#create) | Publishes a new agreement at version 1, required by default. | Credential |
| [`delete`](#delete) | Deletes an agreement together with every acceptance of it. | Credential |
| [`list`](#list) | Lists the tenant's agreements by name, with their full text, version, and settings. | Credential |
| [`listMine`](#listmine) | Returns every agreement of the tenant with the text and whether you have accepted its current version. | Credential |
| [`status`](#status) | Reports who accepted an agreement's current version and which active people still owe it. | Credential |
| [`update`](#update) | Edits an agreement, optionally publishing the change as a new version that everyone must accept again. | Credential |
## accept [#accept]
Records that you accept the given version of an agreement.
**HTTP:** `POST /api/iam/agreements/accept` (requires a credential) · **Browser client:** `client.agreements.accept()`
* **Permission:** None beyond an ordinary session of the agreement's tenant.
* **Audited as:** `agreement:accept`, with the agreement's name and version.
* **Errors:** `VERSION_CONFLICT` (409) when `version` is not the current version; `IMPERSONATION_RESTRICTED` from an
impersonation session; `INVALID_INPUT` for a service account; `ACCESS_DENIED` from a role session or another
tenant's session; `NOT_FOUND` when the agreement is not in this tenant.
Pass the `version` you showed the person, from `listMine`. If the agreement changed in the meantime the call fails,
so nobody accepts text they were not shown. Accepting again restarts the `reacceptAfterDays` clock. The acceptance
applies from the next authorization check; enforced invariants do not guard it.
```ts
const mine = await iam.api.agreements.listMine(credential, { tenantId });
const owed = mine.filter((agreement) => agreement.required && !agreement.accepted);
for (const agreement of owed)
await iam.api.agreements.accept(credential, { tenantId, agreementId: agreement.id, version: agreement.version });
```
```ts title="Signature"
iam.api.agreements.accept(
credential: CredentialInput,
input: { tenantId: string; agreementId: string; version: number },
): Promise<{ accepted: boolean; version: number; acceptedAt: number }>
```
## create [#create]
Publishes a new agreement at version 1, required by default.
**HTTP:** `POST /api/iam/agreements/create` (requires a credential) · **Browser client:** `client.agreements.create()`
* **Permission:** `iam:agreements:manage` on the tenant.
* **Audited as:** `iam:agreements:manage`.
* **Errors:** `CONFLICT` when an agreement with the same name (ignoring case) exists; `LIMIT_EXCEEDED` (409) when
the tenant already has 50; `INVALID_INPUT` for an empty name or one over 100 characters, empty content or content
over 50 000 characters or with control characters other than tabs and line breaks, a `url` that is not http(s), or
a `reacceptAfterDays` outside 1 to 3650; `INVARIANT_VIOLATION` when a new required agreement would make a policy
deny someone an enforced invariant says must be allowed.
`content` is the text people accept (plain text or Markdown); `url` optionally links to the canonical document.
`required: false` makes it optional: it never counts toward `principal.pendingAgreements`, and people who accept it
appear in `principal.agreements`. Publishing a required agreement raises every person's
`principal.pendingAgreements` at once: if a policy already denies on that count, people lose the access it covers
until they accept.
```ts
await iam.api.agreements.create(credential, {
tenantId,
name: 'Acceptable use',
content: 'Use company systems for work. Report incidents within 24 hours.',
url: 'https://intranet.example.com/policies/acceptable-use',
reacceptAfterDays: 365,
});
```
```ts title="Signature"
iam.api.agreements.create(
credential: CredentialInput,
input: AgreementInput & { tenantId: string },
): Promise
```
## delete [#delete]
Deletes an agreement together with every acceptance of it.
**HTTP:** `POST /api/iam/agreements/delete` (requires a credential) · **Browser client:** `client.agreements.delete()`
* **Permission:** `iam:agreements:manage` on the agreement.
* **Audited as:** `iam:agreements:manage`.
* **Errors:** `NOT_FOUND` when the agreement is not in this tenant; `INVARIANT_VIOLATION` when the change would break
an enforced invariant.
Its name disappears from `principal.agreements` and, if it was required, it stops counting toward
`principal.pendingAgreements`. The acceptance history is gone with it; the audit log keeps the `agreement:accept`
events.
```ts title="Signature"
iam.api.agreements.delete(
credential: CredentialInput,
input: { tenantId: string; agreementId: string },
): Promise<{ deleted: boolean }>
```
## list [#list]
Lists the tenant's agreements by name, with their full text, version, and settings.
**HTTP:** `POST /api/iam/agreements/list` (requires a credential) · **Browser client:** `client.agreements.list()`
* **Permission:** `iam:agreements:read` on the tenant.
* **Audited as:** `iam:agreements:read`.
```ts title="Signature"
iam.api.agreements.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listMine [#listmine]
Returns every agreement of the tenant with the text and whether you have accepted its current version.
**HTTP:** `POST /api/iam/agreements/listMine` (requires a credential) · **Browser client:** `client.agreements.listMine()`
* **Permission:** None beyond an ordinary session of the tenant.
* **Audited as:** Not audited; it only reads.
* **Errors:** `ACCESS_DENIED` from a role session or another tenant's session.
Agreements you still owe come first, required ones before optional ones. Each entry carries `accepted` plus, when you
accepted some version, `acceptedAt` and `acceptedVersion`, so you can tell "never accepted" from "accepted an older
version". Use it to render a banner or an acceptance screen; `useAgreements` does this in React and Vue apps.
```ts title="Signature"
iam.api.agreements.listMine(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## status [#status]
Reports who accepted an agreement's current version and which active people still owe it.
**HTTP:** `POST /api/iam/agreements/status` (requires a credential) · **Browser client:** `client.agreements.status()`
* **Permission:** `iam:agreements:read` on the agreement.
* **Audited as:** `iam:agreements:read`.
* **Errors:** `NOT_FOUND` when the agreement is not in this tenant.
`accepted` lists people with a current acceptance (version and time). `pending` lists every other active person,
with `acceptedVersion` when they accepted an older version or their acceptance lapsed. Only people are reported, not
service accounts. Use it to chase stragglers before you turn on a policy that denies on
`principal.pendingAgreements`.
```ts title="Signature"
iam.api.agreements.status(
credential: CredentialInput,
input: { tenantId: string; agreementId: string },
): Promise
```
## update [#update]
Edits an agreement, optionally publishing the change as a new version that everyone must accept again.
**HTTP:** `POST /api/iam/agreements/update` (requires a credential) · **Browser client:** `client.agreements.update()`
* **Permission:** `iam:agreements:manage` on the agreement.
* **Audited as:** `iam:agreements:manage`.
* **Errors:** `NOT_FOUND` when the agreement is not in this tenant; `CONFLICT` for a name another agreement uses;
`INVALID_INPUT` for the same validation as `create`; `INVARIANT_VIOLATION` when the change would break an enforced
invariant.
Fields you omit keep their values. `newVersion: true` increments the version, so every existing acceptance stops
counting; without it acceptances stay valid even if you change the text. `reacceptAfterDays: null` removes the lapse,
and an empty `url` removes the link. A new `reacceptAfterDays` applies to existing acceptances at once, measured from
when each was given. Policies match `principal.agreements` by name, so renaming an agreement changes which
statements match it.
```ts
// Material change: everyone accepts again.
await iam.api.agreements.update(credential, {
tenantId,
agreementId,
content: 'Use company systems for work. Report incidents within 4 hours.',
newVersion: true,
});
```
```ts title="Signature"
iam.api.agreements.update(
credential: CredentialInput,
input: Partial> & {
tenantId: string;
agreementId: string;
newVersion?: boolean;
reacceptAfterDays?: number | null;
},
): Promise
```
# analysis (/docs/reference/api/analysis)
> Access analysis scans a tenant's configuration for risky or stale access, such as administrators without MFA or dormant accounts that still hold roles.
Access analysis scans a tenant's configuration for risky or stale access, such as administrators without MFA or
dormant accounts that still hold roles. It also reports API keys nobody uses, broken manager links, and more. It
answers "what should we fix first?" without anyone reading every policy by hand, and a policy linter catches
documents that do not do what their author intended. Nothing here changes access: findings are observations you act on through the other
groups, or suppress with a recorded reason when a risk is accepted.
## What the scan checks [#what-the-scan-checks]
[`findings`](#findings) runs every check below in one read-only transaction. "Administrator" means a tenant owner
or anyone who holds a role that grants every action (`*` or `iam:*`) on every resource without conditions.
Protected system policies and the Owner role are not reported against.
| Kind | Severity | Reported when |
| ------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `unrestricted-admin-policy` | high | A policy or a role's inline document allows every action on every resource without conditions. |
| `admin-without-mfa` | high | A human administrator has neither an authenticator app nor a passkey, and the tenant does not require MFA. |
| `separation-of-duties` | high | An active person holds roles that a [separation-of-duties rule](/docs/guides/authorization/separation-of-duties) forbids together. |
| `team-maintainers-grant-admin` | high | A [team](/docs/reference/api/teams) (or a team above it) holds an administrator role as standing access, and maintainers manage its membership, so they can make anyone an administrator. |
| `broad-action-wildcard` | medium | A policy allows a service-wide wildcard such as `documents:*` without conditions, which also grants actions added later. |
| `service-account-admin` | medium | A service account is an administrator, so a leaked API key would control the organization. |
| `dormant-access` | medium | A person holding bindings or ownership has not signed in for `dormantDays` (an account that never signed in counts from its creation). |
| `stale-api-key` | medium | An unexpired API key has not been used for `dormantDays`. |
| `trust-without-mfa` | medium | A live [trust](/docs/reference/api/trust) lets its source assume a role without MFA. |
| `standing-privileged-access` | medium | A person holds an administrator role through a direct, permanent binding that is not eligible (just-in-time). |
| `manager-cycle` | medium | A person's manager chain loops back to them. |
| `unattached-policy` | low | A policy is not attached to any role. |
| `unused-role` | low | A role is not bound to anyone and no trust uses it. |
| `empty-role` | low | A role has no inline document and no attached policies. |
| `empty-group-with-access` | low | A group holds role bindings but has no members, so anyone added later inherits them at once. |
| `unused-eligible-binding` | low | An eligible binding has existed for longer than `dormantDays` without a recorded activation. |
| `orphaned-manager` | low | A person's manager no longer exists or is not active, so manager approvals cannot reach them. |
| `policy-lint` | low | A stored policy or a role's inline document has linter warnings of severity `warning` (see [`lintPolicy`](#lintpolicy)). |
| `team-without-maintainer` | low | A team has members but no active maintainer (in it or above it), so only administrators can manage it. |
| `department-without-head` | low | A [department](/docs/reference/api/departments) has people but no active head, so manager approvals routed through the org chart stop there. |
Each finding has a title, a detail that says what to do, and the subject it concerns (a policy, role, identity,
group, trust, credential, delegation, team, or department).
## Suppressing findings [#suppressing-findings]
Some findings describe accepted risk: a break-glass administrator account, or a service account that must be
powerful. Suppress them with a reason so they stop cluttering the results. Finding IDs are deterministic: the same
condition on the same subject always yields the same 24-character ID, so a suppression keeps applying for as long
as the condition holds, and again if it returns later. Suppressed findings are counted in `summary.suppressed` and
listed, with who suppressed them, when, and why, when you pass `includeSuppressed: true`.
| Method | What it does | Access |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`findings`](#findings) | Runs every access-analysis check on the tenant and returns the findings, most severe first, with counts per severity. | Credential |
| [`lintPolicy`](#lintpolicy) | Checks a policy document, or a stored policy, for errors and for statements that likely do not do what they say. | Credential |
| [`suppress`](#suppress) | Hides one finding from future results and records why. | Credential |
| [`unsuppress`](#unsuppress) | Shows a suppressed finding again. | Credential |
## findings [#findings]
Runs every access-analysis check on the tenant and returns the findings, most severe first, with counts per severity.
**HTTP:** `POST /api/iam/analysis/findings` (requires a credential) · **Browser client:** `client.analysis.findings()`
* **Permission:** `iam:analysis:read` on `iam/analysis/*`.
* **Audited as:** `iam:analysis:read`.
* **Errors:** `INVALID_INPUT` when `dormantDays` is outside 1 to 3650.
`dormantDays` (default 90) sets when an unused account, API key, or eligible binding is reported. Suppressed
findings are left out unless `includeSuppressed` is `true`. Run it on a schedule and alert on new high findings;
the `analyze` [CLI command](/docs/reference/cli#analyze) does this with `--fail-on high`, which suits a CI or cron
job.
```ts
const { summary, findings } = await iam.api.analysis.findings(credential, { tenantId, dormantDays: 60 });
for (const finding of findings.filter((item) => item.severity === 'high'))
console.log(finding.title, finding.detail);
```
```ts title="Signature"
iam.api.analysis.findings(
credential: CredentialInput,
input: { tenantId: string; dormantDays?: number; includeSuppressed?: boolean },
): Promise<{
generatedAt: number;
dormantDays: number;
summary: { high: number; medium: number; low: number; suppressed: number };
findings: AccessFinding[];
}>
```
## lintPolicy [#lintpolicy]
Checks a policy document, or a stored policy, for errors and for statements that likely do not do what they say.
**HTTP:** `POST /api/iam/analysis/lintPolicy` (requires a credential) · **Browser client:** `client.analysis.lintPolicy()`
* **Permission:** `iam:policies:read` on the policy (with `policyId`) or on the tenant (with a candidate
`document`).
* **Audited as:** `iam:policies:read`.
* **Errors:** `INVALID_INPUT` unless exactly one of `document` and `policyId` is given; `NOT_FOUND` when the
policy is not in this tenant.
The document is first validated the way storage would validate it, including unknown actions and resource types.
A document storage would reject is not thrown as an error: the result has `valid: false`, the `error` code and
message, and no warnings. A valid document gets `warnings`, each with a `code`, a `severity` (`warning`: probably a
mistake; `info`: worth a look, often intended), the statement index, and a message. Examples include an allow that
makes every holder a full administrator, a condition key the server never sets, a deny that silently never applies
when an optional key is missing, an allow that an unconditional deny shadows, and duplicate statements.
Use it in a policy editor before saving, or in CI over documents kept in version control. Pass `contextKeys` to
name keys your application supplies through `resolveContext`, so they are not reported as unknown. See
[policies](/docs/guides/authorization/policies) and [conditions](/docs/guides/authorization/conditions).
```ts
const result = await iam.api.analysis.lintPolicy(credential, {
tenantId,
document: {
version: 1,
statements: [{ effect: 'allow', actions: ['documents:*'], resources: ['*'] }],
},
});
// result.valid === true; result.warnings[0].code === 'service-wildcard'
```
```ts title="Signature"
iam.api.analysis.lintPolicy(
credential: CredentialInput,
input: {
tenantId: string;
document?: unknown;
policyId?: string;
contextKeys?: string[];
},
): Promise
```
## suppress [#suppress]
Hides one finding from future results and records why.
**HTTP:** `POST /api/iam/analysis/suppress` (requires a credential) · **Browser client:** `client.analysis.suppress()`
* **Permission:** `iam:analysis:update` on `iam/analysis/{findingId}`.
* **Audited as:** `iam:analysis:update`.
* **Errors:** `INVALID_INPUT` when `findingId` is not a 24-character finding ID, or `reason` is empty or longer
than 500 characters.
The reason, the caller, and the time are kept and shown to anyone who lists suppressed findings, so reviewers can
see who accepted which risk. Suppressing a finding again replaces its reason.
```ts title="Signature"
iam.api.analysis.suppress(
credential: CredentialInput,
input: { tenantId: string; findingId: string; reason: string },
): Promise<{ suppressed: boolean }>
```
## unsuppress [#unsuppress]
Shows a suppressed finding again.
**HTTP:** `POST /api/iam/analysis/unsuppress` (requires a credential) · **Browser client:** `client.analysis.unsuppress()`
* **Permission:** `iam:analysis:update` on `iam/analysis/{findingId}`.
* **Audited as:** `iam:analysis:update`.
Unsuppressing a finding that is not suppressed succeeds and changes nothing.
```ts title="Signature"
iam.api.analysis.unsuppress(
credential: CredentialInput,
input: { tenantId: string; findingId: string },
): Promise<{ suppressed: boolean }>
```
# assertions (/docs/reference/api/assertions)
> Assertions are short-lived signed tokens that tell another service who is calling: the caller's identity, tenant, roles, groups, and whether they used MFA.
Assertions are short-lived signed tokens that tell another service who is calling: the caller's identity, tenant,
roles, groups, and whether they used MFA. They solve the "internal service" problem: a reporting service or a worker
behind your API needs to trust the caller, but should not share your session store, hold the deployment secret, or
call Better IAM on every request. The downstream service verifies the token locally with a derived key. An assertion
grants nothing inside Better IAM itself and cannot be used as a credential for it.
## Verifying assertions downstream [#verifying-assertions-downstream]
An assertion is a compact JSON Web Token signed with HS256 under a key derived from the deployment secret. Give the
downstream service that key, `iam.assertionKey()` (64 hex characters), never the secret itself, and verify each
token with `verifyAssertion`:
```ts
import { verifyAssertion } from 'better-iam';
const claims = verifyAssertion(token, {
key: process.env.IAM_ASSERTION_KEY!, // iam.assertionKey(), or iam.assertionKeys() during a secret rotation
audience: 'reports',
issuer: 'https://identity.example.com', // optional: the deployment's base URL origin
});
// claims.sub, claims.tid, claims.roles, claims.groups, claims.mfa
```
It checks the signature in constant time, requires `aud` to equal your `audience` (and `iss` your `issuer`, when
given), and rejects tokens past `exp` or issued in the future, with 30 seconds of clock tolerance
(`toleranceSeconds`). Any failure throws `INVALID_ASSERTION` (401). While you rotate the deployment secret, pass the
array from [`iam.assertionKeys()`](/docs/reference/api#assertionkeys) so tokens signed under the previous secret keep
verifying. In edge runtimes, `verifyAssertionToken` from `@better-iam/next/edge` applies the same rules with Web
Crypto.
The key is symmetric: a service that holds it can verify assertions and could also create them. Share it only with
services you trust. Assertions cannot be revoked, which is why they are short-lived: disabling a person stops new
assertions, but tokens already issued stay valid until they expire.
## Claims [#claims]
| Claim | Meaning |
| ---------------- | ------------------------------------------------------------------------------- |
| `iss` | The deployment's base URL origin. |
| `sub` | The caller's identity id. |
| `aud` | The audience the token was issued for. |
| `iat`, `exp` | Issue and expiry times, in Unix seconds. |
| `jti` | A unique token id, for replay detection. |
| `tid` | The tenant the assertion was issued for. |
| `kind` | The caller's session kind: `user`, `api-key`, `role`, or `session-token`. |
| `mfa` | Whether the session was verified with MFA. |
| `method` | How the person signed in, when known. |
| `impersonatorId` | The administrator behind a "view as" session; `sub` is then the member. |
| `name`, `email` | The caller's display name, and email when set. |
| `roles` | Sorted ids of the roles the caller holds right now, directly or through groups. |
| `groups` | Sorted ids of the caller's current groups. |
| `ext` | The extra claims you passed as `claims`. |
`roles` counts only bindings that grant at this moment: started, not expired, inside their access window, and, for
eligible bindings, activated.
| Method | What it does | Access |
| ----------------- | ----------------------------------------------------------------------------------------------- | ---------- |
| [`issue`](#issue) | Issues a signed assertion about the caller for one audience, valid for five minutes by default. | Credential |
## issue [#issue]
Issues a signed assertion about the caller for one audience, valid for five minutes by default.
**HTTP:** `POST /api/iam/assertions/issue` (requires a credential) · **Browser client:** `client.assertions.issue()`
* **Permission:** `iam:assertions:create` on `iam/{audience}`.
* **Audited as:** `iam:assertions:create`, on the audience.
* **Errors:** `INVALID_INPUT` when `audience` is not a URL-safe identifier, `ttlSeconds` is outside 10 to 3600, or
`claims` exceeds 4 KiB of JSON or reuses a standard claim name; `ACCESS_DENIED` when the caller may not obtain
assertions for that audience, or presents a session token (`sts.getSessionToken`) restricted by a session policy,
including one inherited from a scoped API key (recorded as a denial).
Because the permission is checked on the audience, administrators decide which roles may obtain tokens for which
services: allow `iam:assertions:create` on `iam/reports` for analysts, and on `iam/billing-worker` only for the
billing role. The audience starts with a letter or digit and may contain letters, digits, `.`, `_`, `:`, `/`, and
`-`, up to 256 characters. For an assumed-role session, `roles` holds only the assumed role and `groups` is empty.
Restricted session tokens are refused because the `roles` claim would describe more access than the token allows.
```ts
// In your API: forward the caller to the reports service.
const { token, expiresAt } = await iam.api.assertions.issue(credential, {
tenantId,
audience: 'reports',
ttlSeconds: 120,
claims: { requestId },
});
await fetch('https://reports.internal/run', { headers: { authorization: `Bearer ${token}` } });
```
```ts title="Signature"
iam.api.assertions.issue(
credential: CredentialInput,
input: {
tenantId: string;
audience: string;
ttlSeconds?: number;
claims?: Record;
},
): Promise<{ token: string; expiresAt: number; claims: AssertionClaims }>
```
# audit (/docs/reference/api/audit)
> The audit log records who did what in a tenant; this group searches it, verifies its tamper-evident hash chain, and exports it for archiving.
The audit log records who did what in a tenant; this group searches it, verifies its tamper-evident hash chain, and
exports it for archiving. Every provisioning operation, denial, sign-in, and access-lifecycle event is recorded.
Compliance, incident response, and support all start with "who did what, when": the log answers that, and its
hash chain lets you prove to an auditor that no record was altered or removed afterwards. See
[the audit chain](/docs/guides/events/audit-chain).
## What an audit event holds [#what-an-audit-event-holds]
Each event has an `id`, the tenant, the acting identity (`actorId`), the `action` (such as `iam:groups:update`,
`auth:session:create`, or `binding:activate`), the `resourceId` it concerned, the `outcome` (`allow` or `deny`),
and a `timestamp`. Depending on the event it also carries `metadata`, `rootOverride` when a root administrator
acted through the override, `impersonatorId` when an administrator acted in a "view as" session, and the session
the actor used. Events never contain passwords, tokens, or secrets.
Every event also has a place in the tenant's hash chain: `sequence` (its position, from 1), `previousHash` (the
hash of the event before it), and `hash` (SHA-256 over the event's canonical JSON). Changing, reordering, or
deleting a stored event breaks the chain at that point, which [`verify`](#verify) detects.
All three methods need `iam:audit:read` on the tenant, and each call is itself recorded as an `iam:audit:read`
event, so reading the log leaves a trace. The `audit-verify` and `audit-export`
[CLI commands](/docs/reference/cli#audit-verify) run the same checks and export with deployment access, and record
nothing.
| Method | What it does | Access |
| ------------------- | ------------------------------------------------------------------------------------------------------------- | ---------- |
| [`export`](#export) | Returns a page of the tenant's audit events in chain order as JSON Lines, ready to archive. | Credential |
| [`list`](#list) | Searches the tenant's audit events, newest first, by actor, action, resource, outcome, and time range. | Credential |
| [`verify`](#verify) | Checks the tenant's audit hash chain and reports whether any stored event was altered, reordered, or removed. | Credential |
## export [#export]
Returns a page of the tenant's audit events in chain order as JSON Lines, ready to archive.
**HTTP:** `POST /api/iam/audit/export` (requires a credential) · **Browser client:** `client.audit.export()`
* **Permission:** `iam:audit:read` on the tenant.
* **Audited as:** `iam:audit:read`.
* **Errors:** `INVALID_INPUT` when `fromSequence` is below 1 or `limit` is outside 1 to 10 000.
`body` holds one JSON event per line, including `sequence`, `previousHash`, and `hash`, so an archive can be
verified later anywhere with `verifyAuditChain` from `@better-iam/core`. Start at `fromSequence` (default 1) and
follow `nextSequence` until it is `undefined`; each page's last hash links to the next page's first event. `head`
is the current end of the chain, useful for comparing against your archive. Archive pages as they are; exporting
before [pruning](/docs/reference/api#pruneaudit) keeps the full history verifiable.
```ts
let fromSequence: number | undefined = 1;
while (fromSequence !== undefined) {
const page = await iam.api.audit.export(credential, { tenantId, fromSequence, limit: 5000 });
if (page.count) await archive.append(`${page.body}\n`);
fromSequence = page.nextSequence;
}
```
```ts title="Signature"
iam.api.audit.export(
credential: CredentialInput,
input: { tenantId: string; fromSequence?: number; limit?: number },
): Promise<{
format: 'jsonl';
count: number;
body: string;
firstSequence: number | undefined;
lastSequence: number | undefined;
nextSequence: number | undefined;
head: { sequence: number; hash: string } | null;
}>
```
## list [#list]
Searches the tenant's audit events, newest first, by actor, action, resource, outcome, and time range.
**HTTP:** `POST /api/iam/audit/list` (requires a credential) · **Browser client:** `client.audit.list()`
* **Permission:** `iam:audit:read` on the tenant.
* **Audited as:** `iam:audit:read`.
* **Errors:** `INVALID_INPUT` when `outcome` is not `allow` or `deny`, or `limit` (1 to 1000), `offset`, `from`, or
`to` is out of range.
`action` accepts a glob pattern such as `iam:bindings:*` or `binding:*`; `actorId`, `resourceId`, and `outcome`
match exactly; `from` and `to` bound the timestamp (epoch milliseconds). `limit` defaults to 100. Use it for an
activity feed, a person's history, or an investigation such as "every denial in the last hour".
```ts
const denials = await iam.api.audit.list(credential, {
tenantId,
outcome: 'deny',
from: Date.now() - 60 * 60 * 1000,
});
```
```ts title="Signature"
iam.api.audit.list(
credential: CredentialInput,
input: {
tenantId: string;
limit?: number;
offset?: number;
actorId?: string;
action?: string;
resourceId?: string;
outcome?: 'allow' | 'deny';
from?: number;
to?: number;
},
): Promise
```
## verify [#verify]
Checks the tenant's audit hash chain and reports whether any stored event was altered, reordered, or removed.
**HTTP:** `POST /api/iam/audit/verify` (requires a credential) · **Browser client:** `client.audit.verify()`
* **Permission:** `iam:audit:read` on the tenant.
* **Audited as:** `iam:audit:read`.
* **Errors:** `INVALID_INPUT` when `fromSequence` or `toSequence` is below 1.
The check walks the events in sequence order: the sequences must be contiguous, each `previousHash` must equal
the previous event's hash, and every hash must be recomputable from its event. A full verification also requires
the stored chain head to match the last event, which catches events deleted from the end. `fromSequence` and
`toSequence` verify a window against its own links only, without the head comparison.
`valid` is the answer. On failure, `failure` names the sequence, the event ID, and the reason:
`sequence-gap`, `previous-hash-mismatch`, `hash-mismatch`, or `head-mismatch`. `checked` counts verified events,
and `unchained` counts older events recorded before the chain existed, which are never failures. After a prune the
chain starts at a later sequence, and verification starts from there.
Schedule it and alert when `valid` is `false`. A chain proves nothing was changed by someone without write access
to both the events and the chain head; it cannot stop someone with full database access from rewriting both, so
also export regularly to independent storage and compare heads.
```ts title="Signature"
iam.api.audit.verify(
credential: CredentialInput,
input: { tenantId: string; fromSequence?: number; toSequence?: number },
): Promise<{
head: { sequence: number; hash: string; updatedAt: number } | null;
valid: boolean;
checked: number;
unchained: number;
first?: number;
last?: number;
lastHash?: string;
failure?: { sequence: number; id: string; reason: AuditChainFailure };
}>
```
# auth (/docs/reference/api/auth)
> The auth group signs people in and lets them manage their own account security: passwords, one-time codes, passkeys, MFA, sessions, and recovery.
The `auth` group signs people in and lets them manage their own account security: passwords, one-time codes, passkeys, MFA, sessions, and recovery. It is what your login page, your account settings page, and the links in your recovery emails call.
Unlike the other groups, these methods are not checked against `iam:*` permissions. Each one is either public, because the caller is still proving who they are (sign-up, sign-in, MFA challenges, email links), or it acts only on the caller's own session and account. No method takes another person's ID: administrators manage other people's accounts through [`identities`](/docs/reference/api/identities) (`revokeSessions`, `requestPasswordReset`, `unlock`) and set the rules with [`tenants.setAuthPolicy`](/docs/reference/api/tenants#setauthpolicy).
Changes are recorded as `auth:*` audit events with the person as actor and resource, and with `impersonatorId` when an administrator acted through [impersonation](/docs/guides/authentication/impersonation). They are written only when something happens, never as denials. The one kind of failure that is recorded is `auth:signin:fail`: an attempt that named a real, active account with a wrong password, code, or recovery code. The events land in the tenant's audit log, fan out to webhooks like every other event, and people read their own with `listSecurityEvents`.
Over HTTP every method is `POST {basePath}/auth/{method}`. Methods that issue a session set the session cookie, and a remembered-device token travels in a cookie of its own (see [HTTP behaviour](/docs/guides/authentication/http)). The HTTP handler records the client's IP address and user agent for you. When you call these methods in-process, pass `{ headers: request.headers }` as the credential of an authenticated method, or wrap a public call in `iam.auth.withClient({ ip, userAgent }, fn)`, so network rules, per-address rate limits, and the sign-in record see the real client.
## Sign-in flow and MFA challenges [#sign-in-flow-and-mfa-challenges]
Every way of signing in ends the same way. Once the first factor checks out (a password, a passwordless code or link, or a federated assertion), Better IAM decides whether the person also needs a second factor. MFA is required for root administrators, for anyone with an authenticator enrolled, and for people the tenant policy (`requireMfa`, or `requireMfaForOwners` for owners) or the deployment's `requireMfa` callback covers. The result, a `SignInResult`, has one of two shapes:
* A session: `{ token, session }`. The token is the bearer secret, returned once (only its hash is stored); `session` is the stored session without its secret-derived fields.
* An MFA challenge: `{ mfaRequired: true, challenge, enrollmentRequired, emailCodeAvailable?, passkeyAvailable? }`. The `challenge` is a single-use token, valid for five minutes, proving that the first factor passed. It is not a session: it only unlocks the calls below.
| The challenge says | Next call |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `enrollmentRequired: false` (an authenticator is enrolled) | `verifyMfa` with a code from the authenticator, or `recoverMfa` with a recovery code |
| `enrollmentRequired: true` (nothing enrolled) | `beginMfa`, then `confirmMfa`, to enroll an authenticator on the spot |
| `emailCodeAvailable: true` | `requestMfaCode`, then `verifyMfa` with the emailed code |
| `passkeyAvailable: true` | `beginPasskeyMfa`, then `finishPasskeyMfa` |
A passkey sign-in (`finishPasskeyAuthentication`) skips this step: a user-verified passkey counts as both factors, so it always returns a session.
**Remember this device.** `verifyMfa`, `confirmMfa`, and `finishPasskeyMfa` accept `rememberDevice: true` and then also return a `deviceToken` with its `deviceExpiresAt` (together an `MfaSessionResult`). Passing that token to `signIn` or `finishPasswordless` later satisfies the MFA requirement from that browser; an unknown or expired token simply leads to the normal challenge. The HTTP handler keeps the token in the `better-iam.device` cookie and adds it to those two calls for you. The deployment's `trustedDeviceLifetimeMs` (30 days by default) and the tenant's `trustedDeviceDays` cap how long a device is remembered, either can turn the feature off, and root administrators are never remembered.
**Issuing the session.** Before a session is issued, the person must still be active in an active tenant, must have verified their email when the deployment requires it (`EMAIL_UNVERIFIED`), and must be connecting from a network the tenant accepts (`IP_NOT_ALLOWED`, `IP_BLOCKED`). The new session then:
* records the sign-in `method` (`password`, `passwordless-email`, `passwordless-sms`, `passkey`, or `federated`), which policies see as `principal.authMethod`, and the client's IP address, user agent, and label;
* carries the person's previous sign-in and the failed attempts since then as `session.previousSignIn`, for a "last sign-in" notice, and restarts that count;
* ends the person's oldest sessions when the tenant caps concurrent sessions with `maxSessions`;
* queues a `new-sign-in` email when the client is unfamiliar and sign-in notifications are on (never for a remembered device);
* is audited as `auth:session:create` with the method and client, plus `auth:device:trust` when a device was remembered.
**Failed attempts.** A wrong password, authenticator or emailed code, or recovery code for a real, active account is recorded after the refusal as `auth:signin:fail`, with a `reason` of `password`, `mfa`, or `recovery-code`. It counts toward the next session's `previousSignIn`, and with the deployment's `failedSignInAlerts` set, the person receives one `sign-in-failures` email when the streak reaches that number. Unknown addresses, disabled accounts, and rate-limited attempts are never recorded, so the trail cannot be used to discover accounts. The [sign-in methods](/docs/guides/authentication/sign-in-methods) and [MFA](/docs/guides/authentication/mfa) guides walk through each flow.
## Rate limits and tenant policy [#rate-limits-and-tenant-policy]
Every public flow except `beginMfa`, and every authenticated flow that checks a password or code or sends a message, counts the attempt before it looks at any credential:
1. **Network blocks.** A client address covered by a live [`security.blockNetwork`](/docs/reference/api/security#blocknetwork) block, of the tenant or platform-wide, is refused with `IP_BLOCKED` before anything else, and no counter moves.
2. **Per-address counter.** When the deployment sets `rateLimits.ipAttempts`, all of a tenant's flows share one counter per client address (IPv6 addresses are counted per /64), which stops password spraying across many accounts.
3. **Per-subject counter.** Each flow counts against its own subject: the email address, phone number, or account it names, or the single-use token being redeemed.
| Tier | Default attempts per 15-minute window | Used by |
| --------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Ordinary | 10 (`rateLimits.attempts`) | `signIn`, `reauthenticate`, `changePassword`, `beginPasskeyAuthentication` with an email, and redeeming emailed tokens (`verifyEmail`, `resetPassword`, `confirmEmailChange`) |
| Sensitive | 5 (`rateLimits.sensitiveAttempts`) | `signUp`, every call that sends an email or SMS, checking passwordless and phone codes, `confirmMfa`, every step that answers an MFA challenge, and finishing a passkey sign-in |
| Discovery | 10 times the ordinary limit, per client address | `beginPasskeyAuthentication` without an email |
Every attempt counts, successful or not. Second-factor steps count twice, per challenge and per person, because each correct password mints a new challenge with a fresh budget. A tenant policy's `maxAttempts` lowers the per-subject limits for that tenant (it can never raise them). An exhausted counter fails the call with `RATE_LIMITED` (429). The error carries `retryAfterMs`, the HTTP response adds a `Retry-After` header, and the browser client exposes it as `IamClientError.retryAfterMs`. Counters are stored in the IAM database unless you supply `rateLimits.limiter`, and [`identities.unlock`](/docs/reference/api/identities#unlock) clears a person's counters after a burst of failures.
A tenant's [authentication policy](/docs/guides/authentication/tenant-policy) can only tighten what the deployment allows. Its effects on these methods:
| Policy field | Effect |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allowedMethods` | A sign-in method outside the list fails with `METHOD_NOT_ALLOWED` before any credential is examined, so the answer never reveals whether a password was right: `password` (`signIn`, `reauthenticate`), `passwordless-email` and `passwordless-sms` (`startPasswordless`, `finishPasswordless`), `passkey` (`beginPasskeyAuthentication`, `finishPasskeyAuthentication`). |
| `requireMfa`, `requireMfaForOwners` | Sign-ins return an MFA challenge, and people without a factor enroll on the spot. Sessions that did not pass MFA stop working with `MFA_REQUIRED`, and `disableMfa` is refused. |
| `mfaEmailCodes` | Offers emailed codes (`emailCodeAvailable`) to people with no authenticator. |
| `trustedDeviceDays` | Shortens "remember this device"; `0` turns it off. |
| `allowedIpRanges` | Sessions are issued and used only from these networks; anything else fails with `IP_NOT_ALLOWED`. Needs recorded client addresses. |
| `bindSessionsToIp` | A session works only from the address it was issued from; elsewhere it is refused with `SESSION_NETWORK_MISMATCH` (401) and recorded as `auth:session:mismatch`. |
| `sessionLifetimeMs`, `sessionIdleTimeoutMs`, `maxSessions` | Shorter sessions, and a cap on concurrent sessions per person (the oldest ends). |
| `maxAttempts` | Lower rate limits, as above. |
| `minPasswordLength`, `passwordMinClasses`, `passwordRejectPersonalInfo`, `passwordHistory`, `passwordMaxAgeDays` | Checked by `signUp`, `resetPassword`, and `changePassword` (`WEAK_PASSWORD`, `PASSWORD_REUSED`); an expired password fails `signIn` with `PASSWORD_EXPIRED` until it is reset. The deployment's screening adds `BREACHED_PASSWORD`, and `PASSWORD_CHECK_UNAVAILABLE` when a fail-closed breach check cannot answer. |
| `notifyNewSignIn` | Emails people about sessions from unfamiliar clients. |
A tenant that does not exist, is suspended, or sits under a suspended parent fails every flow with `TENANT_UNAVAILABLE`. Features the deployment has not enabled fail with `FEATURE_DISABLED`: sign-up without `signUpEnabled`, password flows when `emailPassword` is `false`, a passwordless channel without `passwordlessEmail` or `passwordlessSms`, passkeys without `passkeys`, and anything that sends an email or SMS without `sendEmail` or `sendSms`.
## Sessions and recent authentication [#sessions-and-recent-authentication]
Methods whose permission is "the caller's own session" act only on the person behind the credential you pass: `{ token }` with a session token, or `{ headers }` with the incoming request's headers, which carry the session cookie or an `Authorization: Bearer` token. Only a person's session works here. API keys and assumed-role sessions fail with `UNAUTHENTICATED`, as does a session that has expired, idled out, or been revoked. Each call re-checks the session too: it fails with `MFA_REQUIRED` when the person now needs MFA and the session did not pass it, and with `IP_NOT_ALLOWED` or `IP_BLOCKED` when its network is no longer accepted.
Methods that change how someone signs in, or that end sessions, also need **recent authentication**: the session must have been established within the deployment's `recentAuthenticationMs` (five minutes by default). An older session fails with `RECENT_AUTH_REQUIRED`; call [`reauthenticate`](#reauthenticate), or sign in again, and retry with the new session. An impersonation session never qualifies, whatever its age, and fails with `IMPERSONATION_RESTRICTED`, so an administrator viewing as a member cannot change the member's password, factors, devices, or sessions. See [sessions](/docs/guides/authentication/sessions).
Some changes end every session of the person, including the one that made the call, and also forget their remembered devices and pending challenges (sign-in challenges and emailed links): `changePassword`, `resetPassword`, `confirmEmailChange`, `confirmMfa`, `disableMfa`, and `deletePasskey`. That way, a password, address, or factor change always cuts off anyone who was using the old one. `confirmMfa` returns a fresh session; after the others, the person signs in again.
| Method | What it does | Access |
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`beginMfa`](#beginmfa) | Starts enrolling a TOTP authenticator and returns its secret and an `otpauth://` URI to show as a QR code. | Public |
| [`beginPasskeyAuthentication`](#beginpasskeyauthentication) | Starts a passkey sign-in and returns WebAuthn request options plus the `challengeId` to finish it with. | Public |
| [`beginPasskeyMfa`](#beginpasskeymfa) | Starts answering an MFA challenge with a registered passkey instead of a code, returning WebAuthn request options bound to that sign-in. | Public |
| [`beginPasskeyRegistration`](#beginpasskeyregistration) | Returns WebAuthn creation options for adding a passkey to the caller's account. | Credential |
| [`changePassword`](#changepassword) | Replaces the caller's password after checking the current one, and signs the person out everywhere. | Credential |
| [`confirmEmailChange`](#confirmemailchange) | Completes an email change from the link sent to the new address, then signs the person out everywhere. | Public |
| [`confirmMfa`](#confirmmfa) | Checks the first code from a newly enrolled authenticator, turns MFA on, and returns a fresh session with ten recovery codes. | Public |
| [`confirmPhoneVerification`](#confirmphoneverification) | Checks the six-digit SMS code from `startPhoneVerification` and saves the number as the caller's verified phone. | Credential |
| [`deletePasskey`](#deletepasskey) | Removes one of the caller's passkeys and signs the person out everywhere. | Credential |
| [`disableMfa`](#disablemfa) | Removes the caller's authenticator and recovery codes, and signs the person out everywhere. | Credential |
| [`finishPasskeyAuthentication`](#finishpasskeyauthentication) | Verifies a passkey assertion and signs the person in with a session that has already passed MFA. | Public |
| [`finishPasskeyMfa`](#finishpasskeymfa) | Verifies a passkey assertion for a pending sign-in and issues the session, optionally remembering the device. | Public |
| [`finishPasskeyRegistration`](#finishpasskeyregistration) | Verifies the browser's registration response and saves the new passkey on the caller's account. | Credential |
| [`finishPasswordless`](#finishpasswordless) | Redeems a magic-link token or one-time code and signs the person in, or returns an MFA challenge. | Public |
| [`getSession`](#getsession) | Returns the caller's identity, their session, and the session limits in force, so a client can warn before an idle sign-out. | Credential |
| [`listPasskeys`](#listpasskeys) | Lists the caller's passkeys, newest first, without key material. | Credential |
| [`listSecurityEvents`](#listsecurityevents) | Returns the caller's own authentication trail, newest first: sign-ins, failed attempts, sign-outs, and changes to passwords, addresses, factors, passkeys, and devices. | Credential |
| [`listSessions`](#listsessions) | Lists the caller's unexpired sessions in this tenant, marking the one making the call with `current: true`. | Credential |
| [`listTrustedDevices`](#listtrusteddevices) | Lists the caller's remembered devices that have not expired, most recently used first, without their tokens. | Credential |
| [`mfaStatus`](#mfastatus) | Summarizes what the caller has set up: whether an authenticator is enabled, how many recovery codes remain, how many passkeys and remembered devices they have, and whether this session passed MFA. | Credential |
| [`reauthenticate`](#reauthenticate) | Checks the caller's password again and returns a new session that counts as recently authenticated, or an MFA challenge. | Credential |
| [`recoverMfa`](#recovermfa) | Answers an MFA challenge with a single-use recovery code when the person has lost their authenticator. | Public |
| [`regenerateRecoveryCodes`](#regeneraterecoverycodes) | Replaces all of the caller's recovery codes with ten new ones. | Credential |
| [`renamePasskey`](#renamepasskey) | Changes the label of one of the caller's passkeys. | Credential |
| [`requestEmailChange`](#requestemailchange) | Emails a confirmation link to the new address the caller wants to move to. | Credential |
| [`requestEmailVerification`](#requestemailverification) | Emails a new verification link to an address that has not been verified yet. | Public |
| [`requestMfaCode`](#requestmfacode) | Emails a six-digit one-time code that answers the given MFA challenge, for people with no authenticator. | Public |
| [`requestPasswordReset`](#requestpasswordreset) | Emails a password-reset link to a person who has forgotten their password. | Public |
| [`resetPassword`](#resetpassword) | Sets a new password with the token from a password-reset email and ends every session of the person. | Public |
| [`revokeOtherSessions`](#revokeothersessions) | Ends every other session of the caller in this tenant ("sign out everywhere else") and returns how many ended. | Credential |
| [`revokeSession`](#revokesession) | Ends one of the caller's sessions, such as one left open on a shared computer. | Credential |
| [`revokeTrustedDevice`](#revoketrusteddevice) | Forgets one remembered device, so its next sign-in asks for MFA again. | Credential |
| [`revokeTrustedDevices`](#revoketrusteddevices) | Forgets every remembered device of the caller and returns how many were removed. | Credential |
| [`signIn`](#signin) | Checks an email address and password and returns a session, or an MFA challenge when a second factor is needed. | Public |
| [`signOut`](#signout) | Ends the caller's current session. | Credential |
| [`signUp`](#signup) | Registers a new person in a tenant with an email address and password, when self-registration is enabled. | Public |
| [`startPasswordless`](#startpasswordless) | Sends a magic link or a one-time code for signing in without a password. | Public |
| [`startPhoneVerification`](#startphoneverification) | Texts a six-digit code to a phone number the caller wants to verify. | Credential |
| [`verifyEmail`](#verifyemail) | Marks an email address verified using the token from a verification email. | Public |
| [`verifyMfa`](#verifymfa) | Answers an MFA challenge with a code from the person's authenticator, or an emailed code, and issues the session. | Public |
## beginMfa [#beginmfa]
Starts enrolling a TOTP authenticator and returns its secret and an `otpauth://` URI to show as a QR code.
**HTTP:** `POST /api/iam/auth/beginMfa` (no credential) · **Browser client:** `client.auth.beginMfa()`
* **Permission:** None: public during sign-in, with the `{ tenantId, challenge }` of an `mfaRequired` result; or the caller's own session, [authenticated recently](#sessions-and-recent-authentication).
* **Audited as:** not audited; `confirmMfa` records `auth:mfa:enable`.
* **Errors:** `INVALID_CHALLENGE` when the sign-in challenge is invalid or expired; `MFA_REQUIRED` when a sign-in challenge is used by someone who already has an authenticator (they must use it instead); `MFA_ALREADY_ENABLED` (409) when a signed-in caller already has one; `RECENT_AUTH_REQUIRED` for an older session.
The enrollment stays pending for ten minutes and is bound to the credential that started it, so `confirmMfa` must present the same sign-in challenge or the same session. Calling `beginMfa` again replaces a pending enrollment with a new secret. Over HTTP, send `{ tenantId, challenge }` in the body during sign-in, or `{}` with a session.
```ts
// signIn returned { mfaRequired: true, enrollmentRequired: true, challenge }.
const { secret, uri } = await iam.api.auth.beginMfa({ tenantId, challenge: result.challenge });
// Render `uri` as a QR code and show `secret` for manual entry, then call confirmMfa.
```
```ts title="Signature"
iam.api.auth.beginMfa(
credential: MfaCredential,
): Promise<{ secret: string; uri: string }>
```
## beginPasskeyAuthentication [#beginpasskeyauthentication]
Starts a passkey sign-in and returns WebAuthn request options plus the `challengeId` to finish it with.
**HTTP:** `POST /api/iam/auth/beginPasskeyAuthentication` (no credential) · **Browser client:** `client.auth.beginPasskeyAuthentication()`
* **Permission:** None: public.
* **Audited as:** not audited; `finishPasskeyAuthentication` records the session.
* **Errors:** `FEATURE_DISABLED` when passkeys are not configured; `METHOD_NOT_ALLOWED` when the tenant does not allow `passkey`; `INVALID_CREDENTIALS` when `email` names no active person in the tenant; `RATE_LIMITED`.
Without `email`, the options name no credential: the browser's passkey picker or autofill offers any discoverable passkey it holds for your site, and `finishPasskeyAuthentication` finds the account from the credential itself. That mode is rate limited per client address with ten times the ordinary allowance, because a login page starts one on every visit. With `email`, the options list that person's passkeys. The challenge is valid for five minutes, so refresh an autofill request that waits longer. See [passkeys](/docs/guides/authentication/passkeys).
```ts title="Signature"
iam.api.auth.beginPasskeyAuthentication(
input: { tenantId: string; email?: string },
): Promise<{
challengeId: string;
options: Awaited>;
}>
```
## beginPasskeyMfa [#beginpasskeymfa]
Starts answering an MFA challenge with a registered passkey instead of a code, returning WebAuthn request options bound to that sign-in.
**HTTP:** `POST /api/iam/auth/beginPasskeyMfa` (no credential) · **Browser client:** `client.auth.beginPasskeyMfa()`
* **Permission:** None: public, with the `challenge` of an `mfaRequired` result that offered `passkeyAvailable`.
* **Audited as:** not audited; `finishPasskeyMfa` records the session.
* **Errors:** `FEATURE_DISABLED` when passkeys are not configured or the person has none registered; `INVALID_CHALLENGE` when the sign-in challenge is invalid or expired; `RATE_LIMITED` (counted per challenge and per person).
The passkey challenge it returns is valid for five minutes, and the sign-in challenge must still be open when you call `finishPasskeyMfa`.
```ts title="Signature"
iam.api.auth.beginPasskeyMfa(
input: { tenantId: string; challenge: string },
): Promise<{
challengeId: string;
options: Awaited>;
}>
```
## beginPasskeyRegistration [#beginpasskeyregistration]
Returns WebAuthn creation options for adding a passkey to the caller's account.
**HTTP:** `POST /api/iam/auth/beginPasskeyRegistration` (requires a credential) · **Browser client:** `client.auth.beginPasskeyRegistration()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication).
* **Audited as:** not audited; `finishPasskeyRegistration` records `auth:passkey:create`.
* **Errors:** `FEATURE_DISABLED` when passkeys are not configured; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`.
The options require a discoverable credential and user verification, request no attestation, and exclude the passkeys the person already has. The challenge is bound to this session and valid for five minutes.
```ts
// In the browser, with the typed client.
import { startRegistration } from 'better-iam/client/passkeys';
const { challengeId, options } = await client.auth.beginPasskeyRegistration();
const response = await startRegistration({ optionsJSON: options });
await client.auth.finishPasskeyRegistration({ challengeId, response, name: 'Work laptop' });
```
```ts title="Signature"
iam.api.auth.beginPasskeyRegistration(
credentials: CredentialInput,
): Promise<{
challengeId: string;
options: Awaited>;
}>
```
## changePassword [#changepassword]
Replaces the caller's password after checking the current one, and signs the person out everywhere.
**HTTP:** `POST /api/iam/auth/changePassword` (requires a credential) · **Browser client:** `client.auth.changePassword()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication).
* **Audited as:** `auth:password:change`; a wrong current password is recorded as `auth:signin:fail`.
* **Errors:** `INVALID_CREDENTIALS` when `currentPassword` is wrong or the account has no password; `WEAK_PASSWORD`, `BREACHED_PASSWORD`, or `PASSWORD_REUSED` when the new password fails the deployment's screening or the tenant's rules; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`; `RATE_LIMITED`.
Every session ends, including the one that made the call, along with remembered devices and pending challenges, so the person signs in again with the new password. A wrong current password counts as a failed attempt on the account, because someone holding a stolen session may be guessing it. People who have no password set one through `requestPasswordReset` and `resetPassword` instead.
```ts title="Signature"
iam.api.auth.changePassword(
credentials: CredentialInput,
input: { currentPassword: string; password: string },
): Promise<{ success: true }>
```
## confirmEmailChange [#confirmemailchange]
Completes an email change from the link sent to the new address, then signs the person out everywhere.
**HTTP:** `POST /api/iam/auth/confirmEmailChange` (no credential) · **Browser client:** `client.auth.confirmEmailChange()`
* **Permission:** None: public (the token from the `email-change` email is the proof).
* **Audited as:** `auth:email:change`.
* **Errors:** `INVALID_CHALLENGE` when the token is invalid, already used, or older than ten minutes; `UNAUTHENTICATED` when the session that requested the change has ended; `IDENTITY_EXISTS` (409) when another person in the tenant now has the address; `RATE_LIMITED`.
The new address is marked verified, since following the link proves the person receives mail there. Tying the confirmation to the requesting session means a change requested from a session that has since been revoked cannot complete. Every session, remembered device, and pending challenge of the person then ends.
```ts title="Signature"
iam.api.auth.confirmEmailChange(
input: { tenantId: string; token: string },
): Promise<{ success: true }>
```
## confirmMfa [#confirmmfa]
Checks the first code from a newly enrolled authenticator, turns MFA on, and returns a fresh session with ten recovery codes.
**HTTP:** `POST /api/iam/auth/confirmMfa` (no credential) · **Browser client:** `client.auth.confirmMfa()`
* **Permission:** None: public with the sign-in challenge (`credential: { tenantId, challenge }`), or the caller's own session, [authenticated recently](#sessions-and-recent-authentication); either way, the same credential that called `beginMfa`.
* **Audited as:** `auth:mfa:enable`, then `auth:session:create` (and `auth:device:trust` with `rememberDevice`).
* **Errors:** `INVALID_CHALLENGE` when there is no pending enrollment for this credential or it is older than ten minutes; `INVALID_MFA` for a wrong or already-used code; `MFA_REQUIRED` when a sign-in challenge is used by someone who already has an authenticator; `RECENT_AUTH_REQUIRED`; `RATE_LIMITED`.
Show the recovery codes once and ask the person to store them somewhere safe: they are returned only here and by `regenerateRecoveryCodes`, and each works once with `recoverMfa`. Enabling MFA ends every existing session, remembered device, and pending challenge of the person, so the returned session is the only one left. It has passed MFA and keeps the original sign-in method; over HTTP it replaces the session cookie.
```ts
const { token, recoveryCodes } = await iam.api.auth.confirmMfa({
credential: { tenantId, challenge: result.challenge },
code: '492039',
rememberDevice: true,
});
```
```ts title="Signature"
iam.api.auth.confirmMfa(
input: { credential: MfaCredential; code: string; rememberDevice?: boolean },
): Promise
```
## confirmPhoneVerification [#confirmphoneverification]
Checks the six-digit SMS code from `startPhoneVerification` and saves the number as the caller's verified phone.
**HTTP:** `POST /api/iam/auth/confirmPhoneVerification` (requires a credential) · **Browser client:** `client.auth.confirmPhoneVerification()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication); the same session that started the verification.
* **Audited as:** `auth:phone:verify`.
* **Errors:** `INVALID_CHALLENGE` when the code is wrong or expired, or was sent to another session or another number; `PHONE_EXISTS` (409) when another person in the tenant has already verified the number; `INVALID_INPUT` when `phone` is not in E.164 format; `RECENT_AUTH_REQUIRED`; `RATE_LIMITED`.
A verified phone lets the person sign in with SMS codes when the deployment enables `passwordlessSms`.
```ts title="Signature"
iam.api.auth.confirmPhoneVerification(
credentials: CredentialInput,
input: { phone: string; code: string },
): Promise<{ success: true }>
```
## deletePasskey [#deletepasskey]
Removes one of the caller's passkeys and signs the person out everywhere.
**HTTP:** `POST /api/iam/auth/deletePasskey` (requires a credential) · **Browser client:** `client.auth.deletePasskey()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication).
* **Audited as:** `auth:passkey:delete`.
* **Errors:** `NOT_FOUND` when the passkey is not one of the caller's; `LAST_AUTHENTICATOR` (409) when it is the last passkey and the person has no other way to sign in; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`.
"Another way to sign in" means a password, or a verified email address or phone number with passwordless sign-in enabled for that channel. Every session, remembered device, and pending challenge ends, so no session opened with the removed passkey outlives it.
```ts title="Signature"
iam.api.auth.deletePasskey(
credentials: CredentialInput,
input: { id: string },
): Promise<{ success: true }>
```
## disableMfa [#disablemfa]
Removes the caller's authenticator and recovery codes, and signs the person out everywhere.
**HTTP:** `POST /api/iam/auth/disableMfa` (requires a credential) · **Browser client:** `client.auth.disableMfa()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication) and signed in with MFA.
* **Audited as:** `auth:mfa:disable`.
* **Errors:** `MFA_REQUIRED` when the session did not pass MFA, when the person is a root administrator, or when the tenant policy or the deployment's `requireMfa` callback requires MFA for them; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`.
Registered passkeys are kept. Every session, remembered device, and pending challenge of the person ends.
```ts title="Signature"
iam.api.auth.disableMfa(
credentials: CredentialInput,
): Promise<{ success: true }>
```
## finishPasskeyAuthentication [#finishpasskeyauthentication]
Verifies a passkey assertion and signs the person in with a session that has already passed MFA.
**HTTP:** `POST /api/iam/auth/finishPasskeyAuthentication` (no credential) · **Browser client:** `client.auth.finishPasskeyAuthentication()`
* **Permission:** None: public.
* **Audited as:** `auth:session:create` with method `passkey`.
* **Errors:** `INVALID_CHALLENGE` when `challengeId` is invalid or expired; `INVALID_PASSKEY` when the passkey is not registered to an account in this tenant, its user handle does not match, or verification fails; `INVALID_INPUT` when `response` is missing; `METHOD_NOT_ALLOWED`; `FEATURE_DISABLED`; `EMAIL_UNVERIFIED`, `IP_NOT_ALLOWED`, or `IP_BLOCKED` from the session checks; `RATE_LIMITED`.
The server checks the challenge, origin, relying-party ID, user verification, signature, and signature counter, then records the passkey's `lastUsedAt`. Because the passkey proves possession and user verification together, the result is always a session (`SessionResult`), never an MFA challenge. Over HTTP it sets the session cookie.
```ts
// In the browser, with the typed client.
import { startAuthentication } from 'better-iam/client/passkeys';
const { challengeId, options } = await client.auth.beginPasskeyAuthentication({ tenantId });
const response = await startAuthentication({ optionsJSON: options });
await client.auth.finishPasskeyAuthentication({ tenantId, challengeId, response });
```
```ts title="Signature"
iam.api.auth.finishPasskeyAuthentication(
input: {
tenantId: string;
challengeId: string;
response: AuthenticationResponseJSON;
},
): Promise
```
## finishPasskeyMfa [#finishpasskeymfa]
Verifies a passkey assertion for a pending sign-in and issues the session, optionally remembering the device.
**HTTP:** `POST /api/iam/auth/finishPasskeyMfa` (no credential) · **Browser client:** `client.auth.finishPasskeyMfa()`
* **Permission:** None: public, with the `challengeId` from `beginPasskeyMfa`.
* **Audited as:** `auth:session:create` (and `auth:device:trust` with `rememberDevice`).
* **Errors:** `INVALID_CHALLENGE` when the passkey challenge, or the sign-in challenge it belongs to, is invalid or expired; `INVALID_PASSKEY` when the passkey is not the person's or verification fails; `INVALID_INPUT` when `response` is missing; `RATE_LIMITED` (counted per challenge and per person).
Both challenges are consumed together, and the session keeps the method of the first factor, such as `password`. The result is an `MfaSessionResult`: the session, plus `deviceToken` and `deviceExpiresAt` when you asked to remember the device and the policy allows it.
```ts title="Signature"
iam.api.auth.finishPasskeyMfa(
input: {
tenantId: string;
challengeId: string;
response: AuthenticationResponseJSON;
rememberDevice?: boolean;
},
): Promise
```
## finishPasskeyRegistration [#finishpasskeyregistration]
Verifies the browser's registration response and saves the new passkey on the caller's account.
**HTTP:** `POST /api/iam/auth/finishPasskeyRegistration` (requires a credential) · **Browser client:** `client.auth.finishPasskeyRegistration()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication); the same session that called `beginPasskeyRegistration`.
* **Audited as:** `auth:passkey:create`, with the passkey's name.
* **Errors:** `INVALID_CHALLENGE` when the challenge is expired or belongs to another session; `INVALID_PASSKEY` when verification fails; `PASSKEY_EXISTS` (409) when the credential is already registered anywhere in the installation; `INVALID_INPUT` for an empty name or one over 64 characters; `RECENT_AUTH_REQUIRED`.
`name` is optional. Without it, the passkey is labeled from what the authenticator reports about itself: "This device", "Phone", "Security key", or "Passkey". Returns the new passkey's `id` and `name`.
```ts title="Signature"
iam.api.auth.finishPasskeyRegistration(
credentials: CredentialInput,
input: { challengeId: string; response: RegistrationResponseJSON; name?: string },
): Promise<{ id: string; name: string }>
```
## finishPasswordless [#finishpasswordless]
Redeems a magic-link token or one-time code and signs the person in, or returns an MFA challenge.
**HTTP:** `POST /api/iam/auth/finishPasswordless` (no credential) · **Browser client:** `client.auth.finishPasswordless()`
* **Permission:** None: public.
* **Audited as:** `auth:session:create` when a session is issued.
* **Errors:** `INVALID_CHALLENGE` when the token is wrong, already used, older than five minutes, or issued for another destination, or when the person's address or phone changed since it was sent; `METHOD_NOT_ALLOWED`; `FEATURE_DISABLED` when the channel has been turned off; `RATE_LIMITED` (counted per destination).
Pass the same `destination` the message went to: a value starting with `+` is read as a phone number, anything else as an email address. Signing in by email also marks the address verified. The result is a `SignInResult` (see [the sign-in flow](#sign-in-flow-and-mfa-challenges)); a `deviceToken` from "remember this device" satisfies MFA, and the HTTP handler adds it from the device cookie.
```ts title="Signature"
iam.api.auth.finishPasswordless(
input: { tenantId: string; destination: string; token: string; deviceToken?: string },
): Promise
```
## getSession [#getsession]
Returns the caller's identity, their session, and the session limits in force, so a client can warn before an idle sign-out.
**HTTP:** `POST /api/iam/auth/getSession` (requires a credential) · **Browser client:** `client.auth.getSession()`
* **Permission:** The caller's own session.
* **Audited as:** not audited.
* **Errors:** `UNAUTHENTICATED` when the session is missing, expired, idle, or revoked.
`limits` holds the tenant's `lifetimeMs` and `idleTimeoutMs`, `idleExpiresAt` (when the session lapses if nothing uses it again, never later than its absolute expiry), and `now`, the server's clock, so a client can correct for its own clock skew. Like every authenticated call, it counts as activity, which makes it the natural target for a "Stay signed in" button. The identity comes back without its password hash.
```ts title="Signature"
iam.api.auth.getSession(
credentials: CredentialInput,
): Promise<{
identity: SafeIdentity;
session: SafeSession;
limits: {
lifetimeMs: number;
idleTimeoutMs: number;
idleExpiresAt: number;
now: number;
};
}>
```
## listPasskeys [#listpasskeys]
Lists the caller's passkeys, newest first, without key material.
**HTTP:** `POST /api/iam/auth/listPasskeys` (requires a credential) · **Browser client:** `client.auth.listPasskeys()`
* **Permission:** The caller's own session.
* **Audited as:** not audited.
Each entry has its `id`, `name`, `createdAt`, `lastUsedAt` (the last sign-in or MFA answer), `deviceType` (`singleDevice`, or `multiDevice` for a synced passkey), `backedUp`, `transports`, and, when the authenticator reports one, the `aaguid` that identifies its model.
```ts title="Signature"
iam.api.auth.listPasskeys(
credentials: CredentialInput,
): Promise
```
## listSecurityEvents [#listsecurityevents]
Returns the caller's own authentication trail, newest first: sign-ins, failed attempts, sign-outs, and changes to passwords, addresses, factors, passkeys, and devices.
**HTTP:** `POST /api/iam/auth/listSecurityEvents` (requires a credential) · **Browser client:** `client.auth.listSecurityEvents()`
* **Permission:** The caller's own session.
* **Audited as:** not audited.
* **Errors:** `INVALID_INPUT` when `limit` is not an integer from 1 to 200.
It returns up to `limit` (50 by default) of the `auth:*` audit events the person was the actor of in this tenant. Each event carries `impersonatorId` when an administrator acted through impersonation, `sequence` (its position in the [audit chain](/docs/guides/events/audit-chain)), and `metadata` such as the client's `ip` and `userAgent`, the sign-in `method`, or a failure's `reason`. Use it for an account page's recent activity; the tenant's full log is [`audit.list`](/docs/reference/api/audit#list), which needs `iam:audit:read`.
```ts title="Signature"
iam.api.auth.listSecurityEvents(
credentials: CredentialInput,
input?: { limit?: number } | undefined,
): Promise<{
id: string;
action: string;
timestamp: number;
impersonatorId?: string;
sequence?: number;
metadata?: Record;
}[]>
```
## listSessions [#listsessions]
Lists the caller's unexpired sessions in this tenant, marking the one making the call with `current: true`.
**HTTP:** `POST /api/iam/auth/listSessions` (requires a credential) · **Browser client:** `client.auth.listSessions()`
* **Permission:** The caller's own session.
* **Audited as:** not audited.
Each session shows its sign-in `method`, its `client` details, and its timestamps, never its token. Sessions an administrator opened as the person through impersonation appear too, with `impersonatorId`, so people can see them and end them with `revokeSession`.
```ts title="Signature"
iam.api.auth.listSessions(
credentials: CredentialInput,
): Promise<(SafeSession & { current: boolean })[]>
```
## listTrustedDevices [#listtrusteddevices]
Lists the caller's remembered devices that have not expired, most recently used first, without their tokens.
**HTTP:** `POST /api/iam/auth/listTrustedDevices` (requires a credential) · **Browser client:** `client.auth.listTrustedDevices()`
* **Permission:** The caller's own session.
* **Audited as:** not audited.
Each device records when it was remembered, when it expires, when it last vouched for a sign-in (`lastUsedAt`), and the client (IP address, user agent, label) that completed MFA.
```ts title="Signature"
iam.api.auth.listTrustedDevices(
credentials: CredentialInput,
): Promise
```
## mfaStatus [#mfastatus]
Summarizes what the caller has set up: whether an authenticator is enabled, how many recovery codes remain, how many passkeys and remembered devices they have, and whether this session passed MFA.
**HTTP:** `POST /api/iam/auth/mfaStatus` (requires a credential) · **Browser client:** `client.auth.mfaStatus()`
* **Permission:** The caller's own session.
* **Audited as:** not audited.
Use it to drive an account security page, for example to prompt for new recovery codes when `recoveryCodesRemaining` runs low.
```ts title="Signature"
iam.api.auth.mfaStatus(
credentials: CredentialInput,
): Promise<{
enabled: boolean;
recoveryCodesRemaining: number;
passkeys: number;
trustedDevices: number;
sessionMfa: boolean;
}>
```
## reauthenticate [#reauthenticate]
Checks the caller's password again and returns a new session that counts as recently authenticated, or an MFA challenge.
**HTTP:** `POST /api/iam/auth/reauthenticate` (requires a credential) · **Browser client:** `client.auth.reauthenticate()`
* **Permission:** The caller's own session, but not an impersonation session.
* **Audited as:** `auth:session:create` for the new session; a wrong password is recorded as `auth:signin:fail`.
* **Errors:** `INVALID_CREDENTIALS` for a wrong password or an account without one; `METHOD_NOT_ALLOWED` when the tenant does not allow `password`; `IMPERSONATION_RESTRICTED`; `RATE_LIMITED`.
Call it when an operation answers `RECENT_AUTH_REQUIRED`, then retry with the new session. It is a full sign-in: when the person needs MFA the result is an `mfaRequired` challenge (a remembered device does not skip it here), and the session comes from `verifyMfa` or another second-factor call. The old session stays valid; over HTTP the cookie switches to the new one. People without a password get a recent session by signing in again, for example with a passkey.
```ts
const result = await iam.api.auth.reauthenticate({ headers: request.headers }, { password });
```
```ts title="Signature"
iam.api.auth.reauthenticate(
credentials: CredentialInput,
input: { password: string },
): Promise
```
## recoverMfa [#recovermfa]
Answers an MFA challenge with a single-use recovery code when the person has lost their authenticator.
**HTTP:** `POST /api/iam/auth/recoverMfa` (no credential) · **Browser client:** `client.auth.recoverMfa()`
* **Permission:** None: public, with the `challenge` of an `mfaRequired` result.
* **Audited as:** `auth:mfa:recover`, then `auth:session:create`; a wrong code is recorded as `auth:signin:fail`.
* **Errors:** `INVALID_MFA` when the code is wrong or already used, or the person has no authenticator enrolled; `INVALID_CHALLENGE` when the challenge is invalid or expired; `RATE_LIMITED` (counted per challenge and per person).
The code is used up. The authenticator stays enrolled, so once signed in the person should check `mfaStatus` and replace their codes with `regenerateRecoveryCodes`. This call cannot remember the device.
```ts title="Signature"
iam.api.auth.recoverMfa(
input: { tenantId: string; challenge: string; code: string },
): Promise
```
## regenerateRecoveryCodes [#regeneraterecoverycodes]
Replaces all of the caller's recovery codes with ten new ones.
**HTTP:** `POST /api/iam/auth/regenerateRecoveryCodes` (requires a credential) · **Browser client:** `client.auth.regenerateRecoveryCodes()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication) and signed in with MFA.
* **Audited as:** `auth:mfa:recovery-codes`.
* **Errors:** `MFA_REQUIRED` when the session did not pass MFA; `MFA_NOT_ENROLLED` when no authenticator is enabled; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`.
The old codes stop working at once. Show the new ones one time: only their hashes are stored, so they cannot be read back.
```ts title="Signature"
iam.api.auth.regenerateRecoveryCodes(
credentials: CredentialInput,
): Promise<{ recoveryCodes: string[] }>
```
## renamePasskey [#renamepasskey]
Changes the label of one of the caller's passkeys.
**HTTP:** `POST /api/iam/auth/renamePasskey` (requires a credential) · **Browser client:** `client.auth.renamePasskey()`
* **Permission:** The caller's own session.
* **Audited as:** `auth:passkey:rename`, with the new name.
* **Errors:** `NOT_FOUND` when the passkey is not one of the caller's; `INVALID_INPUT` for an empty name or one over 64 characters.
Unlike the other passkey changes, renaming does not need recent authentication.
```ts title="Signature"
iam.api.auth.renamePasskey(
credentials: CredentialInput,
input: { id: string; name: string },
): Promise
```
## requestEmailChange [#requestemailchange]
Emails a confirmation link to the new address the caller wants to move to.
**HTTP:** `POST /api/iam/auth/requestEmailChange` (requires a credential) · **Browser client:** `client.auth.requestEmailChange()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication).
* **Audited as:** not audited; `confirmEmailChange` records `auth:email:change`.
* **Errors:** `INVALID_INPUT` for an invalid address; `FEATURE_DISABLED` when email delivery is not configured; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`; `RATE_LIMITED`.
The address does not change until someone with access to the new mailbox follows the `email-change` link, within ten minutes and while this session is still alive. Whether another person already uses the address is checked at confirmation. Administrators change an address directly with [`identities.update`](/docs/reference/api/identities#update).
```ts title="Signature"
iam.api.auth.requestEmailChange(
credentials: CredentialInput,
input: { email: string },
): Promise<{ success: true }>
```
## requestEmailVerification [#requestemailverification]
Emails a new verification link to an address that has not been verified yet.
**HTTP:** `POST /api/iam/auth/requestEmailVerification` (no credential) · **Browser client:** `client.auth.requestEmailVerification()`
* **Permission:** None: public.
* **Audited as:** not audited; `verifyEmail` records `auth:email:verify`.
* **Errors:** `FEATURE_DISABLED` when email delivery is not configured; `RATE_LIMITED`.
It always succeeds, so it never reveals whether an account exists: the `verify-email` message, valid for 24 hours, goes out only when an active person in the tenant has that address unverified. Use it for a "resend verification email" button.
```ts title="Signature"
iam.api.auth.requestEmailVerification(
input: { tenantId: string; email: string },
): Promise<{ success: true }>
```
## requestMfaCode [#requestmfacode]
Emails a six-digit one-time code that answers the given MFA challenge, for people with no authenticator.
**HTTP:** `POST /api/iam/auth/requestMfaCode` (no credential) · **Browser client:** `client.auth.requestMfaCode()`
* **Permission:** None: public, with the `challenge` of a sign-in that offered `emailCodeAvailable`.
* **Audited as:** not audited.
* **Errors:** `FEATURE_DISABLED` when emailed codes were not offered for this sign-in or the person has no verified address; `INVALID_CHALLENGE` when the challenge is invalid or expired; `RATE_LIMITED` (counted per challenge and per person).
The code works once with `verifyMfa` until the returned `expiresAt`, which never outlives the sign-in challenge. Requesting again replaces the code. Emailed codes are offered only to people with nothing enrolled and a verified address, never to root administrators, and only when the tenant's or the deployment's `mfaEmailCodes` allows them.
```ts title="Signature"
iam.api.auth.requestMfaCode(
input: { tenantId: string; challenge: string },
): Promise<{ success: true; expiresAt: number }>
```
## requestPasswordReset [#requestpasswordreset]
Emails a password-reset link to a person who has forgotten their password.
**HTTP:** `POST /api/iam/auth/requestPasswordReset` (no credential) · **Browser client:** `client.auth.requestPasswordReset()`
* **Permission:** None: public.
* **Audited as:** not audited; `resetPassword` records `auth:password:reset`.
* **Errors:** `FEATURE_DISABLED` when email delivery is not configured or password sign-in is turned off; `RATE_LIMITED`.
It always succeeds, so it never reveals whether an account exists. The `password-reset` message goes out only to an active person whose address is verified, and its token is valid for ten minutes. Administrators can send the same email for a member with [`identities.requestPasswordReset`](/docs/reference/api/identities#requestpasswordreset). See [recovery](/docs/guides/authentication/recovery).
```ts title="Signature"
iam.api.auth.requestPasswordReset(
input: { tenantId: string; email: string },
): Promise<{ success: true }>
```
## resetPassword [#resetpassword]
Sets a new password with the token from a password-reset email and ends every session of the person.
**HTTP:** `POST /api/iam/auth/resetPassword` (no credential) · **Browser client:** `client.auth.resetPassword()`
* **Permission:** None: public (the token is the proof).
* **Audited as:** `auth:password:reset`.
* **Errors:** `INVALID_CHALLENGE` when the token is invalid, already used, or expired; `WEAK_PASSWORD`, `BREACHED_PASSWORD`, or `PASSWORD_REUSED` when the new password fails the deployment's screening or the tenant's rules; `FEATURE_DISABLED` when password sign-in is turned off; `RATE_LIMITED`.
Recovery never signs the person in and never removes MFA: they sign in afterwards with the new password and their second factor. Every session, remembered device, and pending challenge ends, including any other reset links. The new password also restarts the tenant's password-age clock (`passwordMaxAgeDays`), which is how someone whose password expired gets back in.
```ts title="Signature"
iam.api.auth.resetPassword(
input: { tenantId: string; token: string; password: string },
): Promise<{ success: true }>
```
## revokeOtherSessions [#revokeothersessions]
Ends every other session of the caller in this tenant ("sign out everywhere else") and returns how many ended.
**HTTP:** `POST /api/iam/auth/revokeOtherSessions` (requires a credential) · **Browser client:** `client.auth.revokeOtherSessions()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication).
* **Audited as:** `auth:session:revoke-others`.
* **Errors:** `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`.
This includes sessions an administrator opened as the person through impersonation. Remembered devices are not affected, so a device that is still remembered can sign in again without MFA; use `revokeTrustedDevices` to forget those too.
```ts title="Signature"
iam.api.auth.revokeOtherSessions(
credentials: CredentialInput,
): Promise<{ revoked: number }>
```
## revokeSession [#revokesession]
Ends one of the caller's sessions, such as one left open on a shared computer.
**HTTP:** `POST /api/iam/auth/revokeSession` (requires a credential) · **Browser client:** `client.auth.revokeSession()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication).
* **Audited as:** `auth:session:revoke`.
* **Errors:** `NOT_FOUND` when the session is not one of the caller's; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`.
Take the id from `listSessions`. Any impersonation sessions the person opened through the ended session end with it.
```ts title="Signature"
iam.api.auth.revokeSession(
credentials: CredentialInput,
input: { sessionId: string },
): Promise<{ success: true }>
```
## revokeTrustedDevice [#revoketrusteddevice]
Forgets one remembered device, so its next sign-in asks for MFA again.
**HTTP:** `POST /api/iam/auth/revokeTrustedDevice` (requires a credential) · **Browser client:** `client.auth.revokeTrustedDevice()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication).
* **Audited as:** `auth:device:revoke`.
* **Errors:** `NOT_FOUND` when the device is not one of the caller's; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`.
Sessions already issued on that device keep working; end them with `revokeSession`.
```ts title="Signature"
iam.api.auth.revokeTrustedDevice(
credentials: CredentialInput,
input: { deviceId: string },
): Promise<{ success: true }>
```
## revokeTrustedDevices [#revoketrusteddevices]
Forgets every remembered device of the caller and returns how many were removed.
**HTTP:** `POST /api/iam/auth/revokeTrustedDevices` (requires a credential) · **Browser client:** `client.auth.revokeTrustedDevices()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication).
* **Audited as:** `auth:device:revoke`, once, when at least one device was removed.
* **Errors:** `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`.
Over HTTP it also clears the `better-iam.device` cookie in the calling browser. Existing sessions keep working.
```ts title="Signature"
iam.api.auth.revokeTrustedDevices(
credentials: CredentialInput,
): Promise<{ revoked: number }>
```
## signIn [#signin]
Checks an email address and password and returns a session, or an MFA challenge when a second factor is needed.
**HTTP:** `POST /api/iam/auth/signIn` (no credential) · **Browser client:** `client.auth.signIn()`
* **Permission:** None: public.
* **Audited as:** `auth:session:create` when a session is issued; `auth:signin:fail` when a real, active account was given a wrong password.
* **Errors:** `INVALID_CREDENTIALS` (401) for an unknown address, a wrong password, an inactive account, or an account without a password, all indistinguishable; `EMAIL_UNVERIFIED` when verification is required and still pending; `PASSWORD_EXPIRED` when the tenant's maximum password age has passed; `METHOD_NOT_ALLOWED`; `TENANT_UNAVAILABLE`; `IP_NOT_ALLOWED` or `IP_BLOCKED`; `FEATURE_DISABLED` when password sign-in is turned off; `RATE_LIMITED`.
The tenant is never inferred from the email address: you always pass `tenantId`. Unknown addresses go through the same password-hash work as real ones, so response times do not reveal which accounts exist. Pass `deviceToken` from an earlier "remember this device" to skip the second factor in that browser. See [the sign-in flow](#sign-in-flow-and-mfa-challenges) for what to do with each result.
```ts
const result = await iam.auth.withClient({ ip, userAgent }, () =>
iam.api.auth.signIn({ tenantId, email: 'ada@example.com', password }),
);
if ('mfaRequired' in result) {
// Ask for a code, then: iam.api.auth.verifyMfa({ tenantId, challenge: result.challenge, code })
} else {
// result.token is the bearer token; result.session describes the new session.
}
```
```ts title="Signature"
iam.api.auth.signIn(
input: { tenantId: string; email: string; password: string; deviceToken?: string },
): Promise
```
## signOut [#signout]
Ends the caller's current session.
**HTTP:** `POST /api/iam/auth/signOut` (requires a credential) · **Browser client:** `client.auth.signOut()`
* **Permission:** The caller's own session.
* **Audited as:** `auth:session:revoke`.
* **Errors:** `UNAUTHENTICATED` when the session has already ended.
Impersonation sessions the person opened through this session end with it. Over HTTP the session cookie is cleared even when the sign-out is refused, for example because the session had already idled out. Remembered devices stay remembered; `revokeTrustedDevices` forgets them.
```ts title="Signature"
iam.api.auth.signOut(
credentials: CredentialInput,
): Promise<{ success: true }>
```
## signUp [#signup]
Registers a new person in a tenant with an email address and password, when self-registration is enabled.
**HTTP:** `POST /api/iam/auth/signUp` (no credential) · **Browser client:** `client.auth.signUp()`
* **Permission:** None: public.
* **Audited as:** `auth:identity:create`.
* **Errors:** `FEATURE_DISABLED` when `signUpEnabled` is off or password sign-in is turned off; `FORBIDDEN` for the root tenant, whose people only administrators create; `IDENTITY_EXISTS` (409) when the address is taken in this tenant; `LIMIT_EXCEEDED` when the tenant has reached its member limit; `WEAK_PASSWORD` or `BREACHED_PASSWORD`; `TENANT_UNAVAILABLE`; `RATE_LIMITED`.
Sign-up does not sign the person in. When email verification is required (the default once sign-up is enabled), it queues a `verify-email` message valid for 24 hours and returns `verificationRequired: true`, and `signIn` fails with `EMAIL_UNVERIFIED` until `verifyEmail` succeeds. The new person is never an owner or a root administrator, and signing up grants no access by itself: bind roles with [`bindings.create`](/docs/reference/api/bindings#create) or add them to a group.
```ts title="Signature"
iam.api.auth.signUp(
input: { tenantId: string; email: string; name: string; password: string },
): Promise<{ identity: SafeIdentity; verificationRequired: boolean }>
```
## startPasswordless [#startpasswordless]
Sends a magic link or a one-time code for signing in without a password.
**HTTP:** `POST /api/iam/auth/startPasswordless` (no credential) · **Browser client:** `client.auth.startPasswordless()`
* **Permission:** None: public.
* **Audited as:** not audited; `finishPasswordless` records the session.
* **Errors:** `INVALID_INPUT` for an unknown `channel` or `kind`, a malformed destination, or SMS with `kind: 'magic-link'`; `FEATURE_DISABLED` when the channel is not enabled; `METHOD_NOT_ALLOWED` when the tenant does not allow it; `RATE_LIMITED` (counted per destination).
It always succeeds, whether or not the destination belongs to anyone, so it never reveals which accounts exist. A message goes out only to an active person with that email address, or with that phone number verified; SMS supports codes only. The magic-link token or six-digit code is valid for five minutes and works once, with `finishPasswordless`. The message uses the `magic-link` or `code` template, which your `sendEmail` or `sendSms` callback turns into an email or text.
```ts
await iam.api.auth.startPasswordless({
tenantId,
destination: 'ada@example.com',
channel: 'email',
kind: 'code',
});
// Later, with the code the person typed:
const result = await iam.api.auth.finishPasswordless({
tenantId,
destination: 'ada@example.com',
token: '038514',
});
```
```ts title="Signature"
iam.api.auth.startPasswordless(
input: {
tenantId: string;
destination: string;
channel: 'email' | 'sms';
kind: 'magic-link' | 'code';
},
): Promise<{ success: true }>
```
## startPhoneVerification [#startphoneverification]
Texts a six-digit code to a phone number the caller wants to verify.
**HTTP:** `POST /api/iam/auth/startPhoneVerification` (requires a credential) · **Browser client:** `client.auth.startPhoneVerification()`
* **Permission:** The caller's own session, [authenticated recently](#sessions-and-recent-authentication).
* **Audited as:** not audited; `confirmPhoneVerification` records `auth:phone:verify`.
* **Errors:** `INVALID_INPUT` when `phone` is not in E.164 format (such as `+14155550100`); `FEATURE_DISABLED` when SMS delivery is not configured; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`; `RATE_LIMITED`.
The code, sent with the `phone-verify` template, is valid for five minutes and only from the session that requested it.
```ts title="Signature"
iam.api.auth.startPhoneVerification(
credentials: CredentialInput,
input: { phone: string },
): Promise<{ success: true }>
```
## verifyEmail [#verifyemail]
Marks an email address verified using the token from a verification email.
**HTTP:** `POST /api/iam/auth/verifyEmail` (no credential) · **Browser client:** `client.auth.verifyEmail()`
* **Permission:** None: public (the token is the proof).
* **Audited as:** `auth:email:verify`.
* **Errors:** `INVALID_CHALLENGE` when the token is invalid, already used, or older than 24 hours; `UNAUTHENTICATED` when the account is no longer active; `RATE_LIMITED`.
It does not sign the person in: send them to your sign-in page afterwards.
```ts title="Signature"
iam.api.auth.verifyEmail(
input: { tenantId: string; token: string },
): Promise<{ success: true }>
```
## verifyMfa [#verifymfa]
Answers an MFA challenge with a code from the person's authenticator, or an emailed code, and issues the session.
**HTTP:** `POST /api/iam/auth/verifyMfa` (no credential) · **Browser client:** `client.auth.verifyMfa()`
* **Permission:** None: public, with the `challenge` of an `mfaRequired` result.
* **Audited as:** `auth:session:create` (and `auth:device:trust` with `rememberDevice`); a wrong code is recorded as `auth:signin:fail`.
* **Errors:** `INVALID_MFA` for a wrong, expired, or already-used code; `MFA_NOT_ENROLLED` (403) when the person has no authenticator and no emailed code was requested; `INVALID_CHALLENGE` when the challenge is invalid or expired; `RATE_LIMITED` (counted per challenge and per person).
Authenticator codes are six digits. The current 30-second step and one on either side are accepted, and each step only once, so a captured code cannot be replayed. With `rememberDevice: true` the result also carries a `deviceToken` for later sign-ins (see [the sign-in flow](#sign-in-flow-and-mfa-challenges)). Over HTTP it sets the session cookie and, when a device was remembered, the device cookie.
```ts
const session = await iam.api.auth.verifyMfa({
tenantId,
challenge: result.challenge,
code: '492039',
rememberDevice: true,
});
```
```ts title="Signature"
iam.api.auth.verifyMfa(
input: {
tenantId: string;
challenge: string;
code: string;
rememberDevice?: boolean;
},
): Promise
```
# authorities (/docs/reference/api/authorities)
> Grant authorities delegate the right to hand out access, with a ceiling on what anything granted under them may ever allow.
Grant authorities delegate the right to hand out access, with a ceiling on what anything granted under them may
ever allow. In a growing organization not every administrator should be able to grant everything: a support lead
should give support roles to their team but never make someone a tenant administrator. Every role, policy, and
binding records the authority it was created under, and that authority's ceiling bounds it for as long as it
exists. The guide is [grant authorities](/docs/guides/authorization/roles#grant-authorities).
## How delegation works [#how-delegation-works]
* **A ceiling is a boundary.** An authority's `ceiling` is a policy document. Whenever a grant issued under the
authority is evaluated, the ceiling, and every ceiling above it in the chain, is applied as a boundary: whatever
the role says, the result never exceeds them. Nothing is checked for containment when the authority is created;
a child ceiling broader than its parent's is simply cut back by the parent at evaluation time.
* **Authorities form a chain.** A new authority is a child of one of your own, so delegation only ever narrows.
Root administrators receive a root-issued, unrestricted authority automatically, and a tenant's first owner
receives the authority their invitation carried.
* **Authority is not permission.** Holding an authority does not let anyone grant anything. They also need the
permissions, such as `iam:bindings:create` on the roles they may bind. The two together mean "may bind these
roles, and the result never exceeds this ceiling".
* **Revocation cascades.** Revoking an authority disables every binding, role, policy, and API key issued under it,
and under every authority delegated from it, at the next request.
* **Edits stay with their authority.** Only the holder of the authority behind a binding, role, or policy, or root,
may change or delete it.
Records carry the id of their authority as `authorityId`, and
[`identities.export`](/docs/reference/api/identities#export) lists the authorities a person holds.
| Method | What it does | Access |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`create`](#create) | Delegates a new grant authority to an identity, bounded by a ceiling and by your own authority chain. | Credential |
| [`revoke`](#revoke) | Withdraws a grant authority, so everything issued under it, and under authorities delegated from it, stops granting at the next request. | Credential |
## create [#create]
Delegates a new grant authority to an identity, bounded by a ceiling and by your own authority chain.
**HTTP:** `POST /api/iam/authorities/create` (requires a credential) · **Browser client:** `client.authorities.create()`
* **Permission:** `iam:authorities:create` on the recipient (`iam/{identityId}`), an active grant authority of your
own to delegate from, and a recently authenticated session.
* **Audited as:** `iam:authorities:create`.
* **Errors:** `RECENT_AUTH_REQUIRED` when your sign-in is not recent or you call from temporary credentials such as
a role session; `IMPERSONATION_RESTRICTED` from a "view as" session; `ACCESS_DENIED` when you issue authority to
yourself (only root may) or `parentAuthorityId` names an authority that is not yours or is revoked;
`GRANT_AUTHORITY_REQUIRED` when you hold no active authority; `INVALID_POLICY`, `INVALID_ACTION`, or
`INVALID_RESOURCE_TYPE` when the ceiling does not validate against the catalog; `NOT_FOUND` when the identity is
not in this tenant.
`parentAuthorityId` picks which of your authorities the new one hangs under; without it, your root-issued authority
(root) or your first active delegated authority is used. Give the person a role with the matching `iam:*`
permissions as well, or the authority lets them grant nothing.
```ts
// The support lead may hand out support roles, and nothing they grant can exceed tickets and customer reads.
const authority = await iam.api.authorities.create(credential, {
tenantId,
identityId: supportLead.id,
ceiling: {
version: 1,
statements: [{ effect: 'allow', actions: ['tickets:*', 'customers:read'], resources: ['*'] }],
},
});
```
```ts title="Signature"
iam.api.authorities.create(
credential: CredentialInput,
input: {
tenantId: string;
identityId: string;
ceiling: PolicyDocument;
parentAuthorityId?: string;
},
): Promise
```
## revoke [#revoke]
Withdraws a grant authority, so everything issued under it, and under authorities delegated from it, stops granting at the next request.
**HTTP:** `POST /api/iam/authorities/revoke` (requires a credential) · **Browser client:** `client.authorities.revoke()`
* **Permission:** `iam:authorities:revoke` on the authority (`iam/{authorityId}`) and a recently authenticated
session. You must hold the authority's parent (or be root), and you cannot revoke your own.
* **Audited as:** `iam:authorities:revoke`.
* **Errors:** `ACCESS_DENIED` ("Only superior authority can revoke this grant") when you do not hold the parent
authority; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`; `NOT_FOUND` when the authority is not in this
tenant; `INVARIANT_VIOLATION` when the loss of access would break an enforced
[access invariant](/docs/reference/api/invariants).
Use it when a delegated administrator changes teams or leaves; offboarding with
[`identities.offboard`](/docs/reference/api/identities#offboard) does it for you. Removing someone's administrator
role alone does not disable the access they provisioned; revoking their authority does. Revocation deletes nothing:
bindings, roles, and policies issued under the authority remain but grant nothing, and API keys issued under it are
denied on every check. There is no way to reinstate a revoked authority; delegate a new one and re-issue what is
still needed under it. The result is the authority with `revoked: true`.
```ts title="Signature"
iam.api.authorities.revoke(
credential: CredentialInput,
input: { tenantId: string; authorityId: string },
): Promise<{
revoked: boolean;
identityId: string;
ceiling: PolicyDocument;
parentAuthorityId?: string;
id: string;
tenantId: string;
uniqueKey?: string;
}>
```
# billing (/docs/reference/api/billing)
> Billing tells an organization what it spends, on what, and who spent it: usage recorded on meters is priced from a rate card per billing account and month, the…
Billing tells an organization what it spends, on what, and who spent it: usage recorded on meters is priced from a
rate card per billing account and month, then shared out to the people, agents, teams, departments and projects that
produced it. Budgets alert and can refuse usage, and the platform issues monthly invoices (statements) with credit
applied. Invoicing works like Stripe or Orb: drafts, finalized, paid, uncollectible and void invoices; one-off invoice
items; payments and credit notes; plans with fees, seats and meter prices; subscriptions with trials and prorations;
coupons; payment reminders and a printable invoice. Server code records usage, takes payments and runs the jobs
through `iam.billing` (no credential). The repository guide is `docs/billing.md`.
## How spend is computed [#how-spend-is-computed]
A **billing account** is an organization, or a tenant below it with a billing profile of its own. For each account and
month, every meter's total is priced once (tiers and free units apply to the account's total), and the cost is shared
out to the daily roll-ups of usage by quantity; for `unique` meters every person or agent costs the same. Reported
meters carry their own cost per event. Person, team, department and project spend therefore add up to the account's
charges. Meters an account defines for itself are chargeback only (`internal`) and never appear on a statement.
Usage is attributed when it is recorded: the identity, its direct teams (split evenly by default, see the `billing`
option `teamAttribution`), and its department (or its first team's). An agent's usage counts toward its sponsor's
teams and department. Money is in micros of the deployment currency (`costMicros`), with rounded `amount` fields in
currency units.
## Permissions [#permissions]
`iam:billing:read` reads spend, budgets, credits, profiles, invoices, plans, subscriptions and discounts;
`iam:billing:manage` defines meters and prices in the tenant, budgets and profiles, and lets an account's billing
managers subscribe to self-serve plans and redeem coupon codes; `iam:billing:record` records usage. Resources are
`iam/billing`, `iam/billing/meters/{key}`, `iam/billing/budgets`, `iam/billing/profile`, `iam/billing/credits`,
`iam/billing/statements`, `iam/billing/invoice-items`, `iam/billing/plans`, `iam/billing/subscriptions`,
`iam/billing/coupons` and `iam/billing/discounts`. Credits, closing a month, invoice items, payments, credit notes,
plans and coupons are for root administrators only; they act on any account's invoice by calling on the root tenant.
| Method | What it does | Access |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`accounts`](#accounts) | Lists the billing accounts in the tenant's subtree with this month's billable spend, projection, available credit and latest statement. | Credential |
| [`anomalies`](#anomalies) | Lists the people, teams and meters whose spend on one day jumped far above their usual daily spend. | Credential |
| [`cancelSubscription`](#cancelsubscription) | Cancels a subscription at the end of the month (default) or now. | Credential |
| [`changePlan`](#changeplan) | Moves a subscription to another plan now, keeping its seats and what is left of its trial. | Credential |
| [`check`](#check) | Reports whether the caller's own usage is within every enforced budget that covers it. | Credential |
| [`closePeriod`](#closeperiod) | Issues statements for a month that has ended, for every billing account or only `accountId`'s. | Credential |
| [`createBudget`](#createbudget) | Creates a spend budget for the tenant, a tenant below it, a team, a department or a person, per month, quarter or year. | Credential |
| [`createCoupon`](#createcoupon) | Creates a coupon accounts can redeem by code. | Credential |
| [`createCreditNote`](#createcreditnote) | Issues a credit note against a finalized, paid or uncollectible invoice. | Credential |
| [`createInvoiceItem`](#createinvoiceitem) | Adds a one-off charge, or with a negative `amount` a credit, to the billing account's next invoice. | Credential |
| [`createMeter`](#createmeter) | Defines a usage meter: a platform meter on the root tenant, a chargeback meter for the tenant's subtree elsewhere. | Credential |
| [`createPlan`](#createplan) | Defines a plan on the platform (root) tenant. | Credential |
| [`deactivateCoupon`](#deactivatecoupon) | Stops a coupon (by code) from being redeemed; accounts that redeemed it keep their discount. | Credential |
| [`deleteBudget`](#deletebudget) | Deletes a budget and its alert history. | Credential |
| [`deleteInvoiceItem`](#deleteinvoiceitem) | Deletes a pending invoice item. | Credential |
| [`deleteMeter`](#deletemeter) | Deletes a meter that has never recorded usage, with its prices. | Credential |
| [`deleteProfile`](#deleteprofile) | Removes a billing profile, so the tenant's usage rolls into its parent's account again. | Credential |
| [`departmentSpend`](#departmentspend) | Reports a department's spend, with the departments below it, grouped by `identity` by default. | Credential |
| [`exportSpend`](#exportspend) | Returns a spend report as a CSV file: one row per group with the amount, share, events and a column per meter's quantity. | Credential |
| [`exportStatement`](#exportstatement) | Returns a statement as a CSV file: its lines, credit and total, then the breakdown by project, team, department and person. | Credential |
| [`finalizeInvoice`](#finalizeinvoice) | Finalizes a draft invoice: recomputes it with the latest usage and invoice items, numbers it, seals it and emails it. | Credential |
| [`getProfile`](#getprofile) | Returns the billing profile of the tenant (or of `targetTenantId` below it) and the account that pays for it. | Credential |
| [`getStatement`](#getstatement) | Returns one statement with its lines, credit, breakdown and bill-to details, re-checking its content hash. | Credential |
| [`getTerms`](#getterms) | Returns the contract terms of the tenant's billing account: discount, minimum monthly commitment and tax. | Credential |
| [`grantCredit`](#grantcredit) | Grants credit to a billing account, which its statements draw on, earliest expiry first. | Credential |
| [`listBudgets`](#listbudgets) | Lists the tenant's budgets with their spend, projection and the thresholds reached in the current window. | Credential |
| [`listCoupons`](#listcoupons) | Lists the platform's coupons, newest first, with their redemptions. | Credential |
| [`listCreditNotes`](#listcreditnotes) | Lists the credit notes of the billing accounts in the tenant's subtree, or of one `statementId`, newest first. | Credential |
| [`listCredits`](#listcredits) | Lists the credit of the tenant's billing account with the available balance. | Credential |
| [`listDiscounts`](#listdiscounts) | Lists the discounts (redeemed coupons) of the billing accounts in the tenant's subtree. | Credential |
| [`listInvoiceItems`](#listinvoiceitems) | Lists invoice items of the billing accounts in the tenant's subtree, newest first, optionally by `status`. | Credential |
| [`listMeters`](#listmeters) | Lists the meters that reach the tenant with the price that applies to its billing account this month. | Credential |
| [`listPlans`](#listplans) | Lists the platform's plans with their fees, seats and meter prices. | Credential |
| [`listPrices`](#listprices) | Returns a meter's rate card as the tenant may see it, and the entry that prices its account this month. | Credential |
| [`listStatements`](#liststatements) | Lists the statements of the billing accounts in the tenant's subtree (all of them for the root), newest month first. | Credential |
| [`listSubscriptions`](#listsubscriptions) | Lists the subscriptions of the billing accounts in the tenant's subtree, newest first. | Credential |
| [`listUsage`](#listusage) | Lists raw usage events recorded in the tenant for a month, newest first. | Credential |
| [`markPaid`](#markpaid) | Marks a finalized or uncollectible statement paid by recording a `manual` payment of the amount due, with an optional reference. | Credential |
| [`markUncollectible`](#markuncollectible) | Writes a finalized invoice off as uncollectible; a later payment still settles it. | Credential |
| [`mySpend`](#myspend) | Reports the caller's own spend: their usage and that of the agents they sponsor, with the budgets set on them. | Credential |
| [`previewStatement`](#previewstatement) | Builds the statement a billing account would receive for a month (the current one so far by default), without issuing it. | Credential |
| [`quote`](#quote) | Prices a quantity of a meter as a month total for the tenant's billing account. | Credential |
| [`record`](#record) | Records usage of a meter in the tenant, attributed to an identity, their teams and department, with optional tags. | Credential |
| [`recordMany`](#recordmany) | Records up to 100 usage events in one transaction, all or nothing. | Credential |
| [`recordPayment`](#recordpayment) | Records a payment against a finalized or uncollectible invoice. | Credential |
| [`redeemCoupon`](#redeemcoupon) | Redeems a coupon code for the billing account. | Credential |
| [`removeDiscount`](#removediscount) | Ends a billing account's discount now. | Credential |
| [`renderInvoice`](#renderinvoice) | Returns an invoice as a standalone HTML page to print or save as PDF. | Credential |
| [`resumeSubscription`](#resumesubscription) | Undoes a cancellation at the end of the month before it takes effect. | Credential |
| [`revokeCredit`](#revokecredit) | Withdraws what is left of a credit. | Credential |
| [`setPrice`](#setprice) | Sets or removes a rate-card price for a meter the tenant defines: its list price, or a negotiated price for a tenant below it. | Credential |
| [`setProfile`](#setprofile) | Creates or updates a billing profile: company, billing emails, tax ID, address, purchase order, cost center, payment terms. | Credential |
| [`setTerms`](#setterms) | Sets a billing account's contract terms: a discount off the subtotal, a minimum monthly commitment, and the tax invoices add. | Credential |
| [`spend`](#spend) | Reports the spend of the tenant and every tenant below it for a month, grouped and filtered. | Credential |
| [`subscribe`](#subscribe) | Subscribes the billing account to a plan (id or key). | Credential |
| [`teamSpend`](#teamspend) | Reports a team's spend, with the teams below it, grouped by `identity` by default. | Credential |
| [`trend`](#trend) | Returns monthly totals for the last `months` months (1 to 24, default 6), with the filters of `spend`. | Credential |
| [`updateBudget`](#updatebudget) | Changes a budget's name, amount, period, meters, thresholds, alerts or enforcement; its subject stays. | Credential |
| [`updateMeter`](#updatemeter) | Renames a meter, changes its unit or description, or archives it. | Credential |
| [`updatePlan`](#updateplan) | Changes a plan, by id or key: name, items, description, trial, self-serve, archived. | Credential |
| [`updateSubscription`](#updatesubscription) | Changes a subscription's seats. | Credential |
| [`voidStatement`](#voidstatement) | Voids a finalized statement: its credit, invoice items, coupons and advance-billed months come back and the month reopens. | Credential |
## accounts [#accounts]
Lists the billing accounts in the tenant's subtree with this month's billable spend, projection, available credit and latest statement.
**HTTP:** `POST /api/iam/billing/accounts` (requires a credential) · **Browser client:** `client.billing.accounts()`
* **Permission:** `iam:billing:read` on `iam/billing`.
* **Audited as:** `iam:billing:read`.
On the root tenant this is every organization: the platform's receivables view. `monthToDateMicros` counts only
meters defined above the account (what a statement would bill); `totalMicros` includes the account's own chargeback
meters.
```ts title="Signature"
iam.api.billing.accounts(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## anomalies [#anomalies]
Lists the people, teams and meters whose spend on one day jumped far above their usual daily spend.
**HTTP:** `POST /api/iam/billing/anomalies` (requires a credential) · **Browser client:** `client.billing.anomalies()`
* **Permission:** `iam:billing:read` on `iam/billing`.
* **Audited as:** `iam:billing:read`.
* **Errors:** `INVALID_INPUT` for a malformed `day`, `baselineDays` outside 3 to 90, `factor` outside 1.1 to 1000, or a
negative `minimum`.
`day` defaults to yesterday in the billing time zone. A spike is spend at least `factor` (3) times the average over the
`baselineDays` (14) before it and at least `minimum` (10 currency units) more; spending with no baseline counts when it
reaches `minimum` (`factor: null`). The largest increases come first, at most 50. The daily job
`iam.billing.detectAnomalies()` alerts on them once each (`billing:anomaly`, `spend-anomaly` email).
```ts
const { anomalies } = await iam.api.billing.anomalies(admin, { tenantId, factor: 5 });
```
```ts title="Signature"
iam.api.billing.anomalies(
credential: CredentialInput,
input: { tenantId: string } & AnomalyOptions,
): Promise<{ tenantId: string; anomalies: SpendAnomaly[] }>
```
## cancelSubscription [#cancelsubscription]
Cancels a subscription at the end of the month (default) or now.
**HTTP:** `POST /api/iam/billing/cancelSubscription` (requires a credential) · **Browser client:** `client.billing.cancelSubscription()`
* **Permission:** `iam:billing:manage` on `iam/billing/subscriptions` (self-serve plans, at the month's end only), or
a root administrator.
* **Audited as:** `billing:subscription-cancel`.
* **Errors:** `ACCESS_DENIED` (403) for an immediate cancellation by anyone but a root administrator;
`INVALID_TRANSITION` (409) for an ended subscription.
At the month's end the subscription keeps running and `resumeSubscription` can undo it. With `atPeriodEnd: false` it
ends now and the unused part of this month's advance fees and seats is credited as invoice items; arrears items bill
the part of the month it ran.
```ts title="Signature"
iam.api.billing.cancelSubscription(
credential: CredentialInput,
input: { tenantId: string; subscriptionId: string; atPeriodEnd?: boolean },
): Promise<{ subscription: SubscriptionView; invoiceItems: InvoiceItemView[] }>
```
## changePlan [#changeplan]
Moves a subscription to another plan now, keeping its seats and what is left of its trial.
**HTTP:** `POST /api/iam/billing/changePlan` (requires a credential) · **Browser client:** `client.billing.changePlan()`
* **Permission:** `iam:billing:manage` on `iam/billing/subscriptions` when both plans are self-serve, or a root
administrator.
* **Audited as:** `billing:subscription-plan-change`.
* **Errors:** `CONFLICT` (409) when the account already subscribes to the new plan; `INVALID_TRANSITION` (409) for an
ended subscription or an archived plan; `INVALID_INPUT` for the same plan.
The old subscription ends and a new one starts. The old plan's unused advance charges are credited and the new plan's
charges for the rest of the month added, both as invoice items for the next invoice (`invoiceItems`).
```ts title="Signature"
iam.api.billing.changePlan(
credential: CredentialInput,
input: { tenantId: string; subscriptionId: string; plan: string },
): Promise<{ subscription: SubscriptionView; invoiceItems: InvoiceItemView[] }>
```
## check [#check]
Reports whether the caller's own usage is within every enforced budget that covers it.
**HTTP:** `POST /api/iam/billing/check` (requires a credential) · **Browser client:** `client.billing.check()`
* **Permission:** The caller's own session; the tenant must be the caller's tenant or one below it.
* **Audited as:** not audited.
* **Errors:** `ACCESS_DENIED` (403) for a tenant outside the caller's.
Covering budgets are enforced tenant budgets on the tenant or an ancestor, and the caller's own, their teams' (with
parent teams) and their department's (with the departments above it); `meter` narrows to budgets that count it.
`blockedBy` names the first spent budget. Statuses may lag recorded usage by up to 30 seconds. Server code checks any
identity with `iam.billing.check`.
```ts
const verdict = await client.billing.check({ tenantId, meter: 'api-calls' });
if (!verdict.allowed) showBudgetBanner(verdict.blockedBy);
```
```ts title="Signature"
iam.api.billing.check(
credential: CredentialInput,
input: { tenantId: string; meter?: string },
): Promise
```
## closePeriod [#closeperiod]
Issues statements for a month that has ended, for every billing account or only `accountId`'s.
**HTTP:** `POST /api/iam/billing/closePeriod` (requires a credential) · **Browser client:** `client.billing.closePeriod()`
* **Permission:** Root administrators only, called on the root tenant (`iam:billing:manage` on `iam/billing/periods`).
* **Audited as:** `iam:billing:manage`; each statement as `billing:statement`.
* **Errors:** `INVALID_INPUT` for the current or a future month, or when called on another tenant.
`period` defaults to last month. Each invoice bills the month's usage of platform meters, the subscriptions' fees
and seats (the month itself for arrears items, the next month for advance ones), and pending invoice items. Accounts
already invoiced for the month and accounts with nothing to bill are skipped (`skipped.existing`, `skipped.empty`), so
the job is safe to repeat. Coupons apply after the contract discount, credit earliest expiry first; invoices are
emailed (`billing-statement`) to the profile's billing emails or the owners, and raw usage events past their retention
are deleted (`sweptUsage`). With `draft: true` (or the option `billing.autoFinalize: false`) invoices are kept as
drafts, refreshed on every run, and listed in `drafted`; finalize them with `finalizeInvoice`. Schedulers call
`iam.billing.closePeriod()` instead.
```ts title="Signature"
iam.api.billing.closePeriod(
credential: CredentialInput,
input: { tenantId: string; period?: string; accountId?: string; draft?: boolean },
): Promise
```
## createBudget [#createbudget]
Creates a spend budget for the tenant, a tenant below it, a team, a department or a person, per month, quarter or year.
**HTTP:** `POST /api/iam/billing/createBudget` (requires a credential) · **Browser client:** `client.billing.createBudget()`
* **Permission:** `iam:billing:manage` on `iam/billing/budgets`.
* **Audited as:** `billing:budget-create`.
* **Errors:** `CONFLICT` (409) for a name already used (case-insensitive); `LIMIT_EXCEEDED` (409) past 200 budgets;
`NOT_FOUND` for a subject outside the tenant; `INVALID_INPUT` for an amount of 0, a malformed meter key, more than 10
thresholds or notification addresses.
`amount` is in currency units. `thresholds` (default 50, 80, 100 percent) and `forecastAlerts` (default on) drive the
alerts that `iam.billing.checkBudgets()` sends once per window to the owners, the subject (the person, the team's
maintainers or the department head) and `notify.emails`. `enforce` makes covered usage fail once the budget is spent.
The result is the budget with its current standing.
```ts
await iam.api.billing.createBudget(admin, {
tenantId,
name: 'Platform team monthly',
subjectType: 'team',
subjectId: platformTeamId,
amount: 600,
notify: { emails: ['finance@acme.test'] },
});
```
```ts title="Signature"
iam.api.billing.createBudget(
credential: CredentialInput,
input: { tenantId: string } & BillingBudgetInput,
): Promise
```
## createCoupon [#createcoupon]
Creates a coupon accounts can redeem by code.
**HTTP:** `POST /api/iam/billing/createCoupon` (requires a credential) · **Browser client:** `client.billing.createCoupon()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/coupons`), called on the root tenant.
* **Audited as:** `billing:coupon`.
* **Errors:** `CONFLICT` (409) for a code already used; `INVALID_INPUT` for a code that is not 3 to 32 letters, digits,
`-` or `_`, both or neither of `percentOff` and `amountOff`, a percentage outside 0 to 100, `durationInMonths`
without `duration: 'repeating'`, or a `redeemBy` in the past.
Codes are stored in upper case. `duration` is `once` (default: the next invoice), `repeating` (invoices for
`durationInMonths` months from the month of redemption) or `forever`. `maxRedemptions` caps how many accounts may
redeem it.
```ts
await iam.api.billing.createCoupon(root, {
tenantId: rootTenantId,
code: 'LAUNCH20',
percentOff: 20,
duration: 'repeating',
durationInMonths: 3,
});
```
```ts title="Signature"
iam.api.billing.createCoupon(
credential: CredentialInput,
input: {
tenantId: string;
code: string;
name?: string;
percentOff?: number;
amountOff?: number;
duration?: BillingCoupon['duration'];
durationInMonths?: number;
maxRedemptions?: number;
redeemBy?: number;
},
): Promise
```
## createCreditNote [#createcreditnote]
Issues a credit note against a finalized, paid or uncollectible invoice.
**HTTP:** `POST /api/iam/billing/createCreditNote` (requires a credential) · **Browser client:** `client.billing.createCreditNote()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`).
* **Audited as:** `billing:credit-note`.
* **Errors:** `INVALID_INPUT` for an amount above what is left to credit, an unknown `reason`, or a non-boolean
`refund`; `INVALID_TRANSITION` (409) for a draft or void invoice.
`amount` defaults to everything not yet credited. The note first reduces the amount due; the rest (a part already
paid) becomes account credit, or with `refund: true` is recorded as refunded outside Better IAM. `applied` shows the
split. Notes are numbered `{invoice}-CN-01`, `-CN-02`, ...; `reason` is `duplicate`, `fraudulent`, `order_change`,
`product_unsatisfactory` or `other` (default), with an optional `memo`. An invoice the notes and payments cover is paid.
```ts title="Signature"
iam.api.billing.createCreditNote(
credential: CredentialInput,
input: {
tenantId: string;
statementId: string;
amount?: number;
reason?: BillingCreditNote['reason'];
memo?: string;
refund?: boolean;
},
): Promise<{ statement: StatementSummary; creditNote: CreditNoteView }>
```
## createInvoiceItem [#createinvoiceitem]
Adds a one-off charge, or with a negative `amount` a credit, to the billing account's next invoice.
**HTTP:** `POST /api/iam/billing/createInvoiceItem` (requires a credential) · **Browser client:** `client.billing.createInvoiceItem()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/invoice-items`), called on the account.
* **Audited as:** `billing:invoice-item`.
* **Errors:** `INVALID_INPUT` when the tenant is not a billing account, for an empty description, an amount beyond one
billion, a quantity of 0, or more than 20 metadata entries; `BILLING_PERIOD_CLOSED` (409) for a `period` already
invoiced.
`amount` is per unit in currency units and `quantity` defaults to 1; the item's total is rounded to the cent. `period`
bills it on the invoice for that month instead of the next one. When credit items exceed an invoice's charges the
invoice totals 0 and the rest becomes account credit.
```ts
await iam.api.billing.createInvoiceItem(root, {
tenantId: acmeId,
description: 'Onboarding workshop',
amount: 500,
});
```
```ts title="Signature"
iam.api.billing.createInvoiceItem(
credential: CredentialInput,
input: {
tenantId: string;
description: string;
amount: number;
quantity?: number;
period?: string;
metadata?: Record;
},
): Promise
```
## createMeter [#createmeter]
Defines a usage meter: a platform meter on the root tenant, a chargeback meter for the tenant's subtree elsewhere.
**HTTP:** `POST /api/iam/billing/createMeter` (requires a credential) · **Browser client:** `client.billing.createMeter()`
* **Permission:** `iam:billing:manage` on `iam/billing/meters/{key}`.
* **Audited as:** `billing:meter-create`.
* **Errors:** `CONFLICT` (409) when a meter with the key already reaches the tenant; `LIMIT_EXCEEDED` (409) past 100
meters; `INVALID_INPUT` for a malformed key or a `unique` reported meter.
Keys are 1 to 64 lowercase letters, digits, dots, underscores or hyphens, starting with a letter. `aggregation` is
`sum` (default) or `unique` (distinct people and agents per month); `pricing` is `rate-card` (default) or `reported`
(each event carries its cost). Neither can change later.
```ts title="Signature"
iam.api.billing.createMeter(
credential: CredentialInput,
input: {
tenantId: string;
key: string;
name: string;
unit?: string;
description?: string;
aggregation?: BillingMeter['aggregation'];
pricing?: BillingMeter['pricing'];
},
): Promise
```
## createPlan [#createplan]
Defines a plan on the platform (root) tenant.
**HTTP:** `POST /api/iam/billing/createPlan` (requires a credential) · **Browser client:** `client.billing.createPlan()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/plans`), called on the root tenant.
* **Audited as:** `billing:plan`.
* **Errors:** `CONFLICT` (409) for a key already used; `LIMIT_EXCEEDED` (409) past 100 plans; `INVALID_INPUT` for a
malformed key, 0 or more than 20 items, duplicate item ids, two items pricing one meter, or a malformed price.
`items` are `fee` (`amount` per month), `seat` (`unitAmount` per seat and month, `includedSeats`), both billed in
`advance` (default) or `arrears`, and `usage` (`meter` with a `price` that replaces the rate card for subscribers).
`trialDays` (1 to 365) starts subscriptions with a free trial; `selfServe` lets account billing managers subscribe.
```ts
await iam.api.billing.createPlan(root, {
tenantId: rootTenantId,
key: 'team',
name: 'Team',
selfServe: true,
items: [
{ id: 'platform', kind: 'fee', name: 'Platform fee', amount: 99 },
{ id: 'seats', kind: 'seat', name: 'Seats', unitAmount: 12, includedSeats: 3 },
],
});
```
```ts title="Signature"
iam.api.billing.createPlan(
credential: CredentialInput,
input: {
tenantId: string;
key: string;
name: string;
items: unknown[];
description?: string;
trialDays?: number;
selfServe?: boolean;
},
): Promise
```
## deactivateCoupon [#deactivatecoupon]
Stops a coupon (by code) from being redeemed; accounts that redeemed it keep their discount.
**HTTP:** `POST /api/iam/billing/deactivateCoupon` (requires a credential) · **Browser client:** `client.billing.deactivateCoupon()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/coupons`), called on the root tenant.
* **Audited as:** `billing:coupon-deactivate`.
* **Errors:** `NOT_FOUND` for an unknown code.
```ts title="Signature"
iam.api.billing.deactivateCoupon(
credential: CredentialInput,
input: { tenantId: string; code: string },
): Promise
```
## deleteBudget [#deletebudget]
Deletes a budget and its alert history.
**HTTP:** `POST /api/iam/billing/deleteBudget` (requires a credential) · **Browser client:** `client.billing.deleteBudget()`
* **Permission:** `iam:billing:manage` on `iam/billing/budgets`.
* **Audited as:** `billing:budget-delete`.
* **Errors:** `NOT_FOUND` for a budget of another tenant.
```ts title="Signature"
iam.api.billing.deleteBudget(
credential: CredentialInput,
input: { tenantId: string; budgetId: string },
): Promise<{ success: true }>
```
## deleteInvoiceItem [#deleteinvoiceitem]
Deletes a pending invoice item.
**HTTP:** `POST /api/iam/billing/deleteInvoiceItem` (requires a credential) · **Browser client:** `client.billing.deleteInvoiceItem()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/invoice-items`), called on the account.
* **Audited as:** `billing:invoice-item-delete`.
* **Errors:** `INVALID_TRANSITION` (409) for an item already on a finalized invoice (issue a credit note instead).
```ts title="Signature"
iam.api.billing.deleteInvoiceItem(
credential: CredentialInput,
input: { tenantId: string; itemId: string },
): Promise<{ success: true }>
```
## deleteMeter [#deletemeter]
Deletes a meter that has never recorded usage, with its prices.
**HTTP:** `POST /api/iam/billing/deleteMeter` (requires a credential) · **Browser client:** `client.billing.deleteMeter()`
* **Permission:** `iam:billing:manage` on `iam/billing/meters/{key}`.
* **Audited as:** `billing:meter-delete`.
* **Errors:** `RESOURCE_IN_USE` (409) once the meter has recorded usage (archive it with `updateMeter` instead);
`NOT_FOUND` when the tenant does not define the key.
```ts title="Signature"
iam.api.billing.deleteMeter(
credential: CredentialInput,
input: { tenantId: string; key: string },
): Promise<{ success: true; removedPrices: number }>
```
## deleteProfile [#deleteprofile]
Removes a billing profile, so the tenant's usage rolls into its parent's account again.
**HTTP:** `POST /api/iam/billing/deleteProfile` (requires a credential) · **Browser client:** `client.billing.deleteProfile()`
* **Permission:** `iam:billing:manage` on `iam/billing/profile`; below an organization, called from an ancestor with
`targetTenantId`.
* **Audited as:** `billing:profile-delete`.
* **Errors:** `NOT_FOUND` without a profile; `ACCESS_DENIED` (403) when a tenant below an organization removes its own.
```ts title="Signature"
iam.api.billing.deleteProfile(
credential: CredentialInput,
input: { tenantId: string; targetTenantId?: string },
): Promise<{ success: true }>
```
## departmentSpend [#departmentspend]
Reports a department's spend, with the departments below it, grouped by `identity` by default.
**HTTP:** `POST /api/iam/billing/departmentSpend` (requires a credential) · **Browser client:** `client.billing.departmentSpend()`
* **Permission:** The department's head (or the head of a department above it), or `iam:billing:read`.
* **Audited as:** `iam:billing:read`, with `metadata.via` `department-head` or `permission`.
* **Errors:** `ACCESS_DENIED` (403) for anyone else; `NOT_FOUND` for a department outside the tenant.
```ts title="Signature"
iam.api.billing.departmentSpend(
credential: CredentialInput,
input: {
tenantId: string;
departmentId: string;
period?: string;
groupBy?: SpendGroupBy;
},
): Promise
```
## exportSpend [#exportspend]
Returns a spend report as a CSV file: one row per group with the amount, share, events and a column per meter's quantity.
**HTTP:** `POST /api/iam/billing/exportSpend` (requires a credential) · **Browser client:** `client.billing.exportSpend()`
* **Permission:** `iam:billing:read` on `iam/billing`.
* **Audited as:** `iam:billing:read`.
* **Errors:** as `spend`.
It takes the same input as `spend` and returns `filename`, `contentType` and `body` (RFC 4180, CRLF line endings, a
final total row). Cells that a spreadsheet would run as a formula are prefixed with a quote.
```ts title="Signature"
iam.api.billing.exportSpend(
credential: CredentialInput,
input: { tenantId: string } & SpendQuery,
): Promise
```
## exportStatement [#exportstatement]
Returns a statement as a CSV file: its lines, credit and total, then the breakdown by project, team, department and person.
**HTTP:** `POST /api/iam/billing/exportStatement` (requires a credential) · **Browser client:** `client.billing.exportStatement()`
* **Permission:** `iam:billing:read` on `iam/billing/statements`; the statement's account must be the tenant or below it.
* **Audited as:** `iam:billing:read`.
* **Errors:** `NOT_FOUND` for a statement outside the tenant's subtree.
The file is named after the statement number. Department rows carry their cost center.
```ts title="Signature"
iam.api.billing.exportStatement(
credential: CredentialInput,
input: { tenantId: string; statementId: string },
): Promise
```
## finalizeInvoice [#finalizeinvoice]
Finalizes a draft invoice: recomputes it with the latest usage and invoice items, numbers it, seals it and emails it.
**HTTP:** `POST /api/iam/billing/finalizeInvoice` (requires a credential) · **Browser client:** `client.billing.finalizeInvoice()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`), called on the account
or the root tenant.
* **Audited as:** `billing:statement-finalize` and `billing:statement`.
* **Errors:** `INVALID_TRANSITION` (409) for an invoice that is not a draft, or a draft with nothing left to bill.
The invoice then consumes its credit, marks its invoice items `invoiced`, its subscriptions' advance months billed, and
its coupons used. An invoice with nothing to pay is `paid` on issue.
```ts title="Signature"
iam.api.billing.finalizeInvoice(
credential: CredentialInput,
input: { tenantId: string; statementId: string },
): Promise
```
## getProfile [#getprofile]
Returns the billing profile of the tenant (or of `targetTenantId` below it) and the account that pays for it.
**HTTP:** `POST /api/iam/billing/getProfile` (requires a credential) · **Browser client:** `client.billing.getProfile()`
* **Permission:** `iam:billing:read` on `iam/billing/profile`.
* **Audited as:** `iam:billing:read`.
`account.inherited` is true when an ancestor pays; `profile` is null without a profile of its own.
```ts title="Signature"
iam.api.billing.getProfile(
credential: CredentialInput,
input: { tenantId: string; targetTenantId?: string },
): Promise
```
## getStatement [#getstatement]
Returns one statement with its lines, credit, breakdown and bill-to details, re-checking its content hash.
**HTTP:** `POST /api/iam/billing/getStatement` (requires a credential) · **Browser client:** `client.billing.getStatement()`
* **Permission:** `iam:billing:read` on `iam/billing/statements`; the statement's account must be the tenant or below it.
* **Audited as:** `iam:billing:read`.
* **Errors:** `NOT_FOUND` for a statement outside the tenant's subtree.
`verified` is false when the stored content no longer matches the hash computed at issue. `overdue` is true for a
finalized statement past its due date.
```ts title="Signature"
iam.api.billing.getStatement(
credential: CredentialInput,
input: { tenantId: string; statementId: string },
): Promise<{
total: number;
overdue: boolean;
verified: boolean;
number: string;
status: InvoiceStatus;
issuedAt: number;
dueAt: number;
hash: string;
payments?: InvoicePayment[];
amountPaidMicros?: number;
creditNotesMicros?: number;
invoiceItemIds?: string[];
advanceBilled?: { subscriptionId: string; period: string }[];
carryForward?: { creditId: string; amountMicros: number };
reminders?: { days: number; at: number; recipients: number }[];
paidAt?: number;
paidBy?: string;
paymentReference?: string;
voidedAt?: number;
voidedBy?: string;
voidReason?: string;
markedUncollectibleAt?: number;
id: string;
tenantId: string;
uniqueKey?: string;
period: string;
currency: string;
periodStart: number;
periodEnd: number;
lines: StatementLine[];
billingReason?: 'period' | 'subscription' | 'manual';
subtotalMicros: number;
coupons?: { discountId: string; code: string; name: string; amountMicros: number }[];
discount?: { percent: number; amountMicros: number };
commitment?: { minimumMicros: number; trueUpMicros: number };
creditsMicros: number;
creditsApplied: { creditId: string; amountMicros: number }[];
tax?: { label: string; ratePercent: number; amountMicros: number };
totalMicros: number;
breakdown: {
tenants: StatementAllocation[];
teams: StatementAllocation[];
departments: (StatementAllocation & { costCenter?: string })[];
identities: StatementAllocation[];
};
billTo: {
name: string;
companyName?: string;
taxId?: string;
address?: string;
purchaseOrder?: string;
costCenter?: string;
emails: string[];
};
}>
```
## getTerms [#getterms]
Returns the contract terms of the tenant's billing account: discount, minimum monthly commitment and tax.
**HTTP:** `POST /api/iam/billing/getTerms` (requires a credential) · **Browser client:** `client.billing.getTerms()`
* **Permission:** `iam:billing:read` on `iam/billing/terms`.
* **Audited as:** `iam:billing:read`.
When an ancestor pays for the tenant the result only says `inherited: true`; without terms it lists none.
```ts title="Signature"
iam.api.billing.getTerms(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## grantCredit [#grantcredit]
Grants credit to a billing account, which its statements draw on, earliest expiry first.
**HTTP:** `POST /api/iam/billing/grantCredit` (requires a credential) · **Browser client:** `client.billing.grantCredit()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/credits`).
* **Audited as:** `billing:credit-grant`.
* **Errors:** `INVALID_INPUT` when the tenant is not a billing account (an organization or a tenant with a profile), for
an amount of 0, or an `expiresAt` in the past.
```ts
await iam.api.billing.grantCredit(root, { tenantId: acmeId, amount: 100, reason: 'Launch promotion' });
```
```ts title="Signature"
iam.api.billing.grantCredit(
credential: CredentialInput,
input: { tenantId: string; amount: number; reason: string; expiresAt?: number },
): Promise
```
## listBudgets [#listbudgets]
Lists the tenant's budgets with their spend, projection and the thresholds reached in the current window.
**HTTP:** `POST /api/iam/billing/listBudgets` (requires a credential) · **Browser client:** `client.billing.listBudgets()`
* **Permission:** `iam:billing:read` on `iam/billing/budgets`.
* **Audited as:** `iam:billing:read`.
```ts title="Signature"
iam.api.billing.listBudgets(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listCoupons [#listcoupons]
Lists the platform's coupons, newest first, with their redemptions.
**HTTP:** `POST /api/iam/billing/listCoupons` (requires a credential) · **Browser client:** `client.billing.listCoupons()`
* **Permission:** Root administrators only (`iam:billing:read` on `iam/billing/coupons`), called on the root tenant.
* **Audited as:** `iam:billing:read`.
`active` is false for deactivated coupons, those past `redeemBy`, and those out of redemptions.
```ts title="Signature"
iam.api.billing.listCoupons(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listCreditNotes [#listcreditnotes]
Lists the credit notes of the billing accounts in the tenant's subtree, or of one `statementId`, newest first.
**HTTP:** `POST /api/iam/billing/listCreditNotes` (requires a credential) · **Browser client:** `client.billing.listCreditNotes()`
* **Permission:** `iam:billing:read` on `iam/billing/statements`.
* **Audited as:** `iam:billing:read`.
```ts title="Signature"
iam.api.billing.listCreditNotes(
credential: CredentialInput,
input: { tenantId: string; statementId?: string },
): Promise
```
## listCredits [#listcredits]
Lists the credit of the tenant's billing account with the available balance.
**HTTP:** `POST /api/iam/billing/listCredits` (requires a credential) · **Browser client:** `client.billing.listCredits()`
* **Permission:** `iam:billing:read` on `iam/billing/credits`.
* **Audited as:** `iam:billing:read`.
When an ancestor pays for the tenant the list is empty and `inherited` is true.
```ts title="Signature"
iam.api.billing.listCredits(
credential: CredentialInput,
input: { tenantId: string },
): Promise<{
accountId: string;
inherited: boolean;
balanceMicros: number;
balance: number;
credits: CreditView[];
}>
```
## listDiscounts [#listdiscounts]
Lists the discounts (redeemed coupons) of the billing accounts in the tenant's subtree.
**HTTP:** `POST /api/iam/billing/listDiscounts` (requires a credential) · **Browser client:** `client.billing.listDiscounts()`
* **Permission:** `iam:billing:read` on `iam/billing/discounts`.
* **Audited as:** `iam:billing:read`.
`appliedInvoices` counts the invoices a discount reduced; `active` says whether it applies to this month's invoice.
```ts title="Signature"
iam.api.billing.listDiscounts(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listInvoiceItems [#listinvoiceitems]
Lists invoice items of the billing accounts in the tenant's subtree, newest first, optionally by `status`.
**HTTP:** `POST /api/iam/billing/listInvoiceItems` (requires a credential) · **Browser client:** `client.billing.listInvoiceItems()`
* **Permission:** `iam:billing:read` on `iam/billing/invoice-items`.
* **Audited as:** `iam:billing:read`.
* **Errors:** `INVALID_INPUT` for a `status` other than `pending` or `invoiced`.
`pending` items wait for the account's next invoice (or the one for their `period`); `invoiced` items carry the
`statementId` that billed them. `source: 'proration'` items come from subscription changes.
```ts title="Signature"
iam.api.billing.listInvoiceItems(
credential: CredentialInput,
input: { tenantId: string; status?: BillingInvoiceItem['status'] },
): Promise
```
## listMeters [#listmeters]
Lists the meters that reach the tenant with the price that applies to its billing account this month.
**HTTP:** `POST /api/iam/billing/listMeters` (requires a credential) · **Browser client:** `client.billing.listMeters()`
* **Permission:** `iam:billing:read` on `iam/billing`.
* **Audited as:** `iam:billing:read`.
`scope` is `platform` for root meters; `inherited` marks meters an ancestor defines.
```ts title="Signature"
iam.api.billing.listMeters(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listPlans [#listplans]
Lists the platform's plans with their fees, seats and meter prices.
**HTTP:** `POST /api/iam/billing/listPlans` (requires a credential) · **Browser client:** `client.billing.listPlans()`
* **Permission:** `iam:billing:read` on `iam/billing/plans`.
* **Audited as:** `iam:billing:read`.
Tenants see plans that are not archived. Root administrators on the root tenant also get `subscribers` (live
subscriptions) and, with `includeArchived`, archived plans. Amounts are in currency units.
```ts title="Signature"
iam.api.billing.listPlans(
credential: CredentialInput,
input: { tenantId: string; includeArchived?: boolean },
): Promise
```
## listPrices [#listprices]
Returns a meter's rate card as the tenant may see it, and the entry that prices its account this month.
**HTTP:** `POST /api/iam/billing/listPrices` (requires a credential) · **Browser client:** `client.billing.listPrices()`
* **Permission:** `iam:billing:read` on `iam/billing/meters/{key}`.
* **Audited as:** `iam:billing:read`.
* **Errors:** `NOT_FOUND` when no meter with the key reaches the tenant.
Entries for the tenant, its ancestors (list prices) and tenants below it are listed, newest first; negotiated prices
for other organizations are not.
```ts title="Signature"
iam.api.billing.listPrices(
credential: CredentialInput,
input: { tenantId: string; meter: string },
): Promise<{ effective?: PriceView | undefined; meter: MeterView; entries: PriceView[] }>
```
## listStatements [#liststatements]
Lists the statements of the billing accounts in the tenant's subtree (all of them for the root), newest month first.
**HTTP:** `POST /api/iam/billing/listStatements` (requires a credential) · **Browser client:** `client.billing.listStatements()`
* **Permission:** `iam:billing:read` on `iam/billing/statements`.
* **Audited as:** `iam:billing:read`.
Filter by `status` (`finalized`, `paid`, `void`) or `period`.
```ts title="Signature"
iam.api.billing.listStatements(
credential: CredentialInput,
input: { tenantId: string; status?: BillingStatement['status']; period?: string },
): Promise
```
## listSubscriptions [#listsubscriptions]
Lists the subscriptions of the billing accounts in the tenant's subtree, newest first.
**HTTP:** `POST /api/iam/billing/listSubscriptions` (requires a credential) · **Browser client:** `client.billing.listSubscriptions()`
* **Permission:** `iam:billing:read` on `iam/billing/subscriptions`.
* **Audited as:** `iam:billing:read`.
Ended subscriptions are left out unless `includeEnded`. `status` is `trialing`, `active` or `ended`; `billedAdvance`
lists the months already billed in advance.
```ts title="Signature"
iam.api.billing.listSubscriptions(
credential: CredentialInput,
input: { tenantId: string; includeEnded?: boolean },
): Promise
```
## listUsage [#listusage]
Lists raw usage events recorded in the tenant for a month, newest first.
**HTTP:** `POST /api/iam/billing/listUsage` (requires a credential) · **Browser client:** `client.billing.listUsage()`
* **Permission:** `iam:billing:read` on `iam/billing`.
* **Audited as:** `iam:billing:read`.
Only the tenant itself (not its subtree), at most `limit` (1 to 500, default 100) events, optionally for one `meter`
or identity. Events are kept `usageRetentionDays` after their month; reports use daily roll-ups and outlive them.
```ts title="Signature"
iam.api.billing.listUsage(
credential: CredentialInput,
input: {
tenantId: string;
period?: string;
meter?: string;
identityId?: string;
limit?: number;
},
): Promise<{
tenantId: string;
period: string;
events: {
idempotencyKey?: string | undefined;
meterId: string;
meter: string;
quantity: number;
costMicros?: number;
identityId?: string;
agentId?: string;
teamIds: string[];
departmentId?: string;
tags?: Record;
occurredAt: number;
period: string;
day: string;
recordedAt: number;
recordedBy: string;
sourceId?: string;
id: string;
tenantId: string;
}[];
}>
```
## markPaid [#markpaid]
Marks a finalized or uncollectible statement paid by recording a `manual` payment of the amount due, with an optional reference.
**HTTP:** `POST /api/iam/billing/markPaid` (requires a credential) · **Browser client:** `client.billing.markPaid()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`), called on the account
or the root tenant.
* **Audited as:** `billing:statement-paid`.
* **Errors:** `INVALID_TRANSITION` (409) for a draft, paid or void statement.
```ts title="Signature"
iam.api.billing.markPaid(
credential: CredentialInput,
input: { tenantId: string; statementId: string; reference?: string },
): Promise
```
## markUncollectible [#markuncollectible]
Writes a finalized invoice off as uncollectible; a later payment still settles it.
**HTTP:** `POST /api/iam/billing/markUncollectible` (requires a credential) · **Browser client:** `client.billing.markUncollectible()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`).
* **Audited as:** `billing:statement-uncollectible`.
* **Errors:** `INVALID_TRANSITION` (409) for an invoice that is not finalized.
Uncollectible invoices get no payment reminders.
```ts title="Signature"
iam.api.billing.markUncollectible(
credential: CredentialInput,
input: { tenantId: string; statementId: string },
): Promise
```
## mySpend [#myspend]
Reports the caller's own spend: their usage and that of the agents they sponsor, with the budgets set on them.
**HTTP:** `POST /api/iam/billing/mySpend` (requires a credential) · **Browser client:** `client.billing.mySpend()`
* **Permission:** The caller's own session in their own tenant.
* **Audited as:** not audited.
* **Errors:** `ACCESS_DENIED` (403) for another tenant; `INVALID_INPUT` for a `groupBy` other than `meter`, `day`,
`agent`, `tenant` or `tag:{name}`.
```ts title="Signature"
iam.api.billing.mySpend(
credential: CredentialInput,
input: { tenantId: string; period?: string; groupBy?: SpendGroupBy },
): Promise
```
## previewStatement [#previewstatement]
Builds the statement a billing account would receive for a month (the current one so far by default), without issuing it.
**HTTP:** `POST /api/iam/billing/previewStatement` (requires a credential) · **Browser client:** `client.billing.previewStatement()`
* **Permission:** `iam:billing:read` on `iam/billing/statements`.
* **Audited as:** `iam:billing:read`.
* **Errors:** `INVALID_INPUT` when the tenant is not a billing account.
```ts title="Signature"
iam.api.billing.previewStatement(
credential: CredentialInput,
input: { tenantId: string; period?: string },
): Promise
```
## quote [#quote]
Prices a quantity of a meter as a month total for the tenant's billing account.
**HTTP:** `POST /api/iam/billing/quote` (requires a credential) · **Browser client:** `client.billing.quote()`
* **Permission:** `iam:billing:read` on `iam/billing/meters/{key}`.
* **Audited as:** `iam:billing:read`.
* **Errors:** `NOT_FOUND` for an unknown meter; `INVALID_INPUT` for a reported meter or a negative quantity.
Tiers and free units apply as they would to the account's month total; `unpriced` is true without a price.
```ts title="Signature"
iam.api.billing.quote(
credential: CredentialInput,
input: { tenantId: string; meter: string; quantity: number; period?: string },
): Promise<| {
price: PriceView;
meter: string;
quantity: number;
period: string;
currency: string;
amountMicros: number;
amount: number;
}
| {
unpriced: true;
meter: string;
quantity: number;
period: string;
currency: string;
amountMicros: number;
amount: number;
}>
```
## record [#record]
Records usage of a meter in the tenant, attributed to an identity, their teams and department, with optional tags.
**HTTP:** `POST /api/iam/billing/record` (requires a credential) · **Browser client:** `client.billing.record()`
* **Permission:** `iam:billing:record` on `iam/billing/meters/{key}`.
* **Audited as:** `iam:billing:record`.
* **Errors:** `NOT_FOUND` for an unknown meter or an identity outside the tenant and its ancestors; `METER_ARCHIVED`
(409); `BILLING_PERIOD_CLOSED` (409) for a month already invoiced; `SPEND_LIMIT_REACHED` (402) with
`enforceBudgets` under a spent enforced budget; `CONFLICT` (409) for an idempotency key used on another meter;
`INVALID_INPUT` for a negative quantity, `cost` on a rate-card meter or none on a reported one, or `occurredAt` more
than five minutes ahead or a year back.
`idempotencyKey` makes retries safe: a repeat returns the first receipt with `duplicate: true`. `teamId` attributes the
usage to one team instead of the person's own. Server code records without an audit event per call through
`iam.billing.record`, the usual choice for metering.
```ts
await client.billing.record({
tenantId,
meter: 'api-calls',
quantity: 1,
identityId,
tags: { endpoint: 'search' },
idempotencyKey: requestId,
});
```
```ts title="Signature"
iam.api.billing.record(
credential: CredentialInput,
input: UsageInput & { enforceBudgets?: boolean },
): Promise
```
## recordMany [#recordmany]
Records up to 100 usage events in one transaction, all or nothing.
**HTTP:** `POST /api/iam/billing/recordMany` (requires a credential) · **Browser client:** `client.billing.recordMany()`
* **Permission:** `iam:billing:record` on `iam/billing` in the call's tenant.
* **Audited as:** `iam:billing:record`.
* **Errors:** as `record`; `INVALID_INPUT` for an event outside the tenant's subtree or more than 100 events.
```ts title="Signature"
iam.api.billing.recordMany(
credential: CredentialInput,
input: {
tenantId: string;
events: Omit & { tenantId?: string }[];
},
): Promise<{ receipts: UsageReceipt[] }>
```
## recordPayment [#recordpayment]
Records a payment against a finalized or uncollectible invoice.
**HTTP:** `POST /api/iam/billing/recordPayment` (requires a credential) · **Browser client:** `client.billing.recordPayment()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`), called on the account
or the root tenant.
* **Audited as:** `billing:payment`.
* **Errors:** `INVALID_TRANSITION` (409) for a draft, paid or void invoice; `INVALID_INPUT` for an amount of 0 or a
`receivedAt` in the future.
`amount` (currency units) defaults to the amount due; `method` (default `manual`) and `reference` describe it. A
partial payment leaves the rest due; once payments and credit notes cover the invoice it is `paid`. A payment above
the amount due keeps the excess as account credit (`payment.overpaymentMicros`, `payment.creditId`). Payment
processors report payments through `iam.billing.recordPayment({ statementId | number, amount, idempotencyKey })`
instead, where the idempotency key makes webhook redelivery safe.
```ts
await iam.api.billing.recordPayment(root, {
tenantId: acmeId,
statementId,
amount: 300,
method: 'bank_transfer',
reference: 'wire-88213',
});
```
```ts title="Signature"
iam.api.billing.recordPayment(
credential: CredentialInput,
input: {
tenantId: string;
statementId: string;
amount?: number;
method?: string;
reference?: string;
receivedAt?: number;
},
): Promise<{ statement: StatementSummary; payment: InvoicePayment }>
```
## redeemCoupon [#redeemcoupon]
Redeems a coupon code for the billing account.
**HTTP:** `POST /api/iam/billing/redeemCoupon` (requires a credential) · **Browser client:** `client.billing.redeemCoupon()`
* **Permission:** `iam:billing:manage` on `iam/billing/discounts` in the account.
* **Audited as:** `billing:coupon-redeem`.
* **Errors:** `CONFLICT` (409) when the account already redeemed the coupon; `NOT_FOUND` for a code that is unknown,
inactive, past `redeemBy` or out of redemptions (all answer the same); `INVALID_INPUT` when the tenant is not a
billing account.
The resulting discount applies to the account's invoices after the contract discount, in the order codes were
redeemed. Voiding an invoice gives a one-off discount back.
```ts title="Signature"
iam.api.billing.redeemCoupon(
credential: CredentialInput,
input: { tenantId: string; code: string },
): Promise
```
## removeDiscount [#removediscount]
Ends a billing account's discount now.
**HTTP:** `POST /api/iam/billing/removeDiscount` (requires a credential) · **Browser client:** `client.billing.removeDiscount()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/discounts`), called on the account.
* **Audited as:** `billing:discount-remove`.
* **Errors:** `INVALID_TRANSITION` (409) for a discount that has already ended.
```ts title="Signature"
iam.api.billing.removeDiscount(
credential: CredentialInput,
input: { tenantId: string; discountId: string },
): Promise
```
## renderInvoice [#renderinvoice]
Returns an invoice as a standalone HTML page to print or save as PDF.
**HTTP:** `POST /api/iam/billing/renderInvoice` (requires a credential) · **Browser client:** `client.billing.renderInvoice()`
* **Permission:** `iam:billing:read` on `iam/billing/statements`.
* **Audited as:** `iam:billing:read`.
* **Errors:** `NOT_FOUND` for an invoice outside the tenant's subtree.
The result is `{ filename, contentType, body }`. The page names the issuer (the `billing.issuer` option: name,
address, tax ID, contact, and `paymentInstructions` under the totals), the bill-to details, every line with its tier
sub-lines, service period and proration, then discounts, coupons, credit, tax, payments, credit notes and the amount
due. It has no scripts and only inline styles, so it can be served with a strict content security policy.
```ts title="Signature"
iam.api.billing.renderInvoice(
credential: CredentialInput,
input: { tenantId: string; statementId: string },
): Promise
```
## resumeSubscription [#resumesubscription]
Undoes a cancellation at the end of the month before it takes effect.
**HTTP:** `POST /api/iam/billing/resumeSubscription` (requires a credential) · **Browser client:** `client.billing.resumeSubscription()`
* **Permission:** `iam:billing:manage` on `iam/billing/subscriptions` (self-serve plans), or a root administrator.
* **Audited as:** `billing:subscription-resume`.
* **Errors:** `INVALID_TRANSITION` (409) for a subscription that is not set to cancel, or has ended.
```ts title="Signature"
iam.api.billing.resumeSubscription(
credential: CredentialInput,
input: { tenantId: string; subscriptionId: string },
): Promise
```
## revokeCredit [#revokecredit]
Withdraws what is left of a credit.
**HTTP:** `POST /api/iam/billing/revokeCredit` (requires a credential) · **Browser client:** `client.billing.revokeCredit()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/credits`).
* **Audited as:** `billing:credit-revoke`.
* **Errors:** `INVALID_TRANSITION` (409) for a credit already revoked.
```ts title="Signature"
iam.api.billing.revokeCredit(
credential: CredentialInput,
input: { tenantId: string; creditId: string },
): Promise
```
## setPrice [#setprice]
Sets or removes a rate-card price for a meter the tenant defines: its list price, or a negotiated price for a tenant below it.
**HTTP:** `POST /api/iam/billing/setPrice` (requires a credential) · **Browser client:** `client.billing.setPrice()`
* **Permission:** `iam:billing:manage` on `iam/billing/meters/{key}` in the defining tenant.
* **Audited as:** `billing:price`.
* **Errors:** `NOT_FOUND` when the tenant does not define the meter; `INVALID_INPUT` for a reported meter, a target
outside the subtree, malformed tiers, or `effectiveFrom` more than 12 months back; `BILLING_PERIOD_CLOSED` (409) when
a month from `effectiveFrom` on is already invoiced for the tenants it reaches.
`price` takes currency units: `per-unit` (`unitAmount`), `graduated` or `volume` (`tiers` of `upTo` and `unitAmount`,
optional `flatAmount`, the last `upTo: null`), or `package` (`packageSize`, `packageAmount`), each with an optional
`includedQuantity`. `effectiveFrom` (default this month) starts the price; `price: null` removes that entry.
```ts
await iam.api.billing.setPrice(root, {
tenantId: rootTenantId,
meter: 'api-calls',
targetTenantId: acmeId,
price: { model: 'per-unit', unitAmount: 0.0003 },
note: 'Enterprise agreement',
});
```
```ts title="Signature"
iam.api.billing.setPrice(
credential: CredentialInput,
input: {
tenantId: string;
meter: string;
targetTenantId?: string;
effectiveFrom?: string;
price: Record | null;
note?: string;
},
): Promise
```
## setProfile [#setprofile]
Creates or updates a billing profile: company, billing emails, tax ID, address, purchase order, cost center, payment terms.
**HTTP:** `POST /api/iam/billing/setProfile` (requires a credential) · **Browser client:** `client.billing.setProfile()`
* **Permission:** `iam:billing:manage` on `iam/billing/profile`; a new profile below an organization is created from an
ancestor with `targetTenantId`.
* **Audited as:** `billing:profile`.
* **Errors:** `ACCESS_DENIED` (403) when a tenant below an organization creates its own; `INVALID_INPUT` on the root
tenant or for more than 10 emails.
A profile below an organization makes that tenant a billing account of its own, a decision for its parent; afterwards
the tenant's own billing managers may keep it up to date. `null` clears a field.
```ts title="Signature"
iam.api.billing.setProfile(
credential: CredentialInput,
input: {
tenantId: string;
targetTenantId?: string;
companyName?: string | null;
billingEmails?: string[];
taxId?: string | null;
address?: string | null;
purchaseOrder?: string | null;
costCenter?: string | null;
paymentTermsDays?: number | null;
},
): Promise
```
## setTerms [#setterms]
Sets a billing account's contract terms: a discount off the subtotal, a minimum monthly commitment, and the tax invoices add.
**HTTP:** `POST /api/iam/billing/setTerms` (requires a credential) · **Browser client:** `client.billing.setTerms()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/terms`), called on the account.
* **Audited as:** `billing:terms`.
* **Errors:** `INVALID_INPUT` when the tenant is not a billing account, for a percentage outside 0 to 100, or a
negative commitment.
Invoices issued from then on take the subtotal of their lines, subtract `discountPercent`, add the shortfall below
`minimumCommitment` (currency units per month, monthly invoices only) as a true-up, subtract coupons and credit, and
add `taxRatePercent` (labelled `taxLabel`, `Tax` by default) on the rest. `null` clears a term. Spend reports stay at
rate-card prices.
```ts
await iam.api.billing.setTerms(root, {
tenantId: acmeId,
discountPercent: 10,
minimumCommitment: 1000,
taxRatePercent: 20,
taxLabel: 'VAT',
});
```
```ts title="Signature"
iam.api.billing.setTerms(
credential: CredentialInput,
input: {
tenantId: string;
discountPercent?: number | null;
minimumCommitment?: number | null;
taxRatePercent?: number | null;
taxLabel?: string | null;
},
): Promise
```
## spend [#spend]
Reports the spend of the tenant and every tenant below it for a month, grouped and filtered.
**HTTP:** `POST /api/iam/billing/spend` (requires a credential) · **Browser client:** `client.billing.spend()`
* **Permission:** `iam:billing:read` on `iam/billing`.
* **Audited as:** `iam:billing:read`.
* **Errors:** `NOT_FOUND` for a team, department or sub-tenant outside the scope; `INVALID_INPUT` for an unknown
`groupBy` or a malformed month.
`groupBy` is `meter` (default), `identity`, `agent`, `team`, `department`, `tenant`, `day` or `tag:{name}`. Filters:
`meter`, `identityId`, `teamId`, `departmentId`, `subTenantId`, `rollUp: false` (no sub-teams or sub-departments) and
`billableOnly` (leave out chargeback meters). Each row has `costMicros`, `amount`, `share` (percent) and `quantities`
per meter; the current month carries a linear `forecast`. For showback, `shareUnattributed: true` (grouped by
`identity`, `agent`, `team` or `department`) spreads the unattributed row over the other groups by their share of
spend, reported as `sharedMicros` per row and for the report.
```ts
const report = await iam.api.billing.spend(admin, { tenantId, groupBy: 'team' });
```
```ts title="Signature"
iam.api.billing.spend(
credential: CredentialInput,
input: { tenantId: string; period?: string; groupBy?: SpendGroupBy } & SpendFilters,
): Promise
```
## subscribe [#subscribe]
Subscribes the billing account to a plan (id or key).
**HTTP:** `POST /api/iam/billing/subscribe` (requires a credential) · **Browser client:** `client.billing.subscribe()`
* **Permission:** `iam:billing:manage` on `iam/billing/subscriptions`: self-serve plans for the account's billing
managers, any plan for root administrators.
* **Audited as:** `billing:subscription`.
* **Errors:** `ACCESS_DENIED` (403) for a plan that is not self-serve, or `trialDays` from anyone but a root
administrator; `CONFLICT` (409) when the account already subscribes to the plan; `INVALID_TRANSITION` (409) for an
archived plan; `INVALID_INPUT` when the tenant is not a billing account.
`seats` defaults to 1. Outside a trial the rest of this month's advance fees and seats are invoiced at once (the
result's `invoice`, `billingReason: 'subscription'`); after that each monthly invoice bills the month ahead. A trial
bills nothing until it ends; the part of the month after it is billed on that month's invoice.
```ts
const { subscription, invoice } = await iam.api.billing.subscribe(orgAdmin, {
tenantId: acmeId,
plan: 'team',
seats: 8,
});
```
```ts title="Signature"
iam.api.billing.subscribe(
credential: CredentialInput,
input: { tenantId: string; plan: string; seats?: number; trialDays?: number },
): Promise<{ subscription: SubscriptionView; invoice?: StatementSummary }>
```
## teamSpend [#teamspend]
Reports a team's spend, with the teams below it, grouped by `identity` by default.
**HTTP:** `POST /api/iam/billing/teamSpend` (requires a credential) · **Browser client:** `client.billing.teamSpend()`
* **Permission:** The team's maintainers (and those of teams above it), or `iam:billing:read`.
* **Audited as:** `iam:billing:read`, with `metadata.via` `team-maintainer` or `permission`.
* **Errors:** `ACCESS_DENIED` (403) for anyone else; `NOT_FOUND` for a team outside the tenant.
```ts title="Signature"
iam.api.billing.teamSpend(
credential: CredentialInput,
input: { tenantId: string; teamId: string; period?: string; groupBy?: SpendGroupBy },
): Promise
```
## trend [#trend]
Returns monthly totals for the last `months` months (1 to 24, default 6), with the filters of `spend`.
**HTTP:** `POST /api/iam/billing/trend` (requires a credential) · **Browser client:** `client.billing.trend()`
* **Permission:** `iam:billing:read` on `iam/billing`.
* **Audited as:** `iam:billing:read`.
```ts title="Signature"
iam.api.billing.trend(
credential: CredentialInput,
input: { tenantId: string; months?: number } & SpendFilters,
): Promise
```
## updateBudget [#updatebudget]
Changes a budget's name, amount, period, meters, thresholds, alerts or enforcement; its subject stays.
**HTTP:** `POST /api/iam/billing/updateBudget` (requires a credential) · **Browser client:** `client.billing.updateBudget()`
* **Permission:** `iam:billing:manage` on `iam/billing/budgets`.
* **Audited as:** `billing:budget-update`.
* **Errors:** `CONFLICT` (409) for a name another budget uses; `NOT_FOUND` for a budget of another tenant.
`meters: null` counts every meter again.
```ts title="Signature"
iam.api.billing.updateBudget(
credential: CredentialInput,
input: { tenantId: string; budgetId: string } & Partial<
Omit
> & { meters?: string[] | null },
): Promise
```
## updateMeter [#updatemeter]
Renames a meter, changes its unit or description, or archives it.
**HTTP:** `POST /api/iam/billing/updateMeter` (requires a credential) · **Browser client:** `client.billing.updateMeter()`
* **Permission:** `iam:billing:manage` on `iam/billing/meters/{key}`.
* **Audited as:** `billing:meter-update`.
* **Errors:** `NOT_FOUND` when the tenant does not define the key; `INVALID_INPUT` when changing `aggregation` or
`pricing`.
An archived meter refuses new usage (`METER_ARCHIVED`) and keeps its history; `archived: false` restores it.
```ts title="Signature"
iam.api.billing.updateMeter(
credential: CredentialInput,
input: {
tenantId: string;
key: string;
name?: string;
unit?: string;
description?: string | null;
archived?: boolean;
},
): Promise
```
## updatePlan [#updateplan]
Changes a plan, by id or key: name, items, description, trial, self-serve, archived.
**HTTP:** `POST /api/iam/billing/updatePlan` (requires a credential) · **Browser client:** `client.billing.updatePlan()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/plans`), called on the root tenant.
* **Audited as:** `billing:plan-update`.
* **Errors:** `NOT_FOUND` for an unknown plan; `INVALID_INPUT` as for `createPlan`.
`null` clears `description` or `trialDays`. An archived plan takes no new subscriptions. Changes apply to invoices
drawn up afterwards.
```ts title="Signature"
iam.api.billing.updatePlan(
credential: CredentialInput,
input: {
tenantId: string;
plan: string;
name?: string;
items?: unknown[];
description?: string | null;
trialDays?: number | null;
selfServe?: boolean;
archived?: boolean;
},
): Promise
```
## updateSubscription [#updatesubscription]
Changes a subscription's seats.
**HTTP:** `POST /api/iam/billing/updateSubscription` (requires a credential) · **Browser client:** `client.billing.updateSubscription()`
* **Permission:** `iam:billing:manage` on `iam/billing/subscriptions` (self-serve plans), or a root administrator.
* **Audited as:** `billing:subscription-update`.
* **Errors:** `INVALID_TRANSITION` (409) for an ended subscription; `INVALID_INPUT` for seats outside 0 to 1000000.
Seats billed in advance for this month are prorated as invoice items on the next invoice (`invoiceItems`): added seats
for the rest of the month, credit for removed ones. Arrears seats follow the seat history on their own.
```ts title="Signature"
iam.api.billing.updateSubscription(
credential: CredentialInput,
input: { tenantId: string; subscriptionId: string; seats: number },
): Promise<{ subscription: SubscriptionView; invoiceItems: InvoiceItemView[] }>
```
## voidStatement [#voidstatement]
Voids a finalized statement: its credit, invoice items, coupons and advance-billed months come back and the month reopens.
**HTTP:** `POST /api/iam/billing/voidStatement` (requires a credential) · **Browser client:** `client.billing.voidStatement()`
* **Permission:** Root administrators only (`iam:billing:manage` on `iam/billing/statements`), called on the account
or the root tenant.
* **Audited as:** `billing:statement-void`.
* **Errors:** `INVALID_TRANSITION` (409) for a statement already void, a draft, or one with payments or credit notes
(issue a credit note instead).
Fix the usage, then close the month again: the new statement gets a new number. Credit the invoice created from a
negative balance is revoked.
```ts title="Signature"
iam.api.billing.voidStatement(
credential: CredentialInput,
input: { tenantId: string; statementId: string; reason: string },
): Promise
```
# bindings (/docs/reference/api/bindings)
> Bindings give a role to a person, service account, or group, and control when that role actually applies.
Bindings give a role to a person, service account, or group, and control when that role actually applies. A role
grants nothing until it is bound: a binding links one role to one subject, and the subject holds the role for as
long as the binding says. A binding can be standing (always on), temporary, future-dated, limited to recurring
hours, or eligible, which means the subject may take the role just in time, for a bounded period, with a reason, MFA,
or a second person's approval. This group creates and changes bindings, runs that activation and approval flow, and
answers "who holds what".
## Why a binding needs grant authority [#why-a-binding-needs-grant-authority]
Every binding is created under a [grant authority](/docs/guides/authorization/roles#grant-authorities): the
delegated right to hand out access, with a ceiling on what it may ever grant. Pass `authorityId` to pick one of
your own authorities; otherwise the server uses your root-issued authority (root administrators) or your first
active delegated authority. A caller with no active authority gets `GRANT_AUTHORITY_REQUIRED`. The binding stores
the authority's id, and that matters in three ways:
* **Ceiling.** Whatever the role says, the binding grants no more than the ceilings of its authority chain allow.
They are applied as boundaries every time a request is evaluated.
* **Revocation.** When the authority, or any authority above it, is
[revoked](/docs/reference/api/authorities#revoke), the binding grants nothing from the next request on.
* **Ownership.** Only the holder of that authority, or root, may update or delete the binding or revoke activations
of it. A junior administrator cannot undo a senior administrator's grants.
The permission and the authority answer different questions. `iam:bindings:create` on `iam/{roleId}` decides which
roles you may bind; the authority caps what those bindings can reach.
## When a binding applies [#when-a-binding-applies]
A binding without dates or rules is standing: it applies until someone removes it. Four options narrow it without
changing the role:
| Option | Effect |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `expiresAt` | Epoch milliseconds, in the future and at most ten years ahead. The binding stops granting at that instant, is left out of listings unless you pass `includeExpired: true`, and is deleted with its activations by the purge worker ([`purgeDeleted`](/docs/reference/api#purgedeleted)). |
| `startsAt` | Epoch milliseconds, not in the past and at most ten years ahead, before `expiresAt`. The binding is listed with its start but grants nothing until then. Separation-of-duties rules already count it. |
| `window` | `{ from, to, timeZone, days? }`: `HH:MM` times in an IANA time zone, and weekdays 0 (Sunday) to 6 (every day by default). Outside the window the binding grants nothing. A window whose end is not after its start wraps past midnight and counts as the day it starts on. |
| `eligible` | The binding grants nothing until its subject activates it, for a bounded time. |
A group binding reaches every live member of the group, so membership changes grant and remove the role too. See
[temporary access](/docs/guides/authorization/temporary-access) for dates and windows.
## Just-in-time activation [#just-in-time-activation]
An eligible binding records that its subject *may* hold a role. The subject activates it with `activate` when they
need the role, and it lapses on its own. That keeps administrator and production roles out of everyday sessions
while every elevation leaves a record with a reason. The full walkthrough is in
[just-in-time elevation](/docs/guides/privileged-access/elevation).
Each eligible binding carries its activation rules:
| Setting | Effect |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `maxActivationMs` | The longest activation, from one minute to seven days. Defaults to one hour. |
| `requireJustification` | The activation must state a reason (at most 2048 characters). It is stored on the activation and in the audit trail. |
| `requireMfa` | Only a session that completed MFA may activate. |
| `requireApproval` | Activation becomes a request that an approver grants or denies. |
| `approverGroupId` | Only live members of this group (or root) may decide, and they are emailed each request. |
| `managerApproval` | The requester's manager (`managerId`) may decide as well, and is emailed each request. |
Passing any of these for a binding that is not eligible fails with `INVALID_INPUT`; explicit `false` flags are
accepted, so forms can send every checkbox. The tenant's
[access policy](/docs/reference/api/tenants#setaccesspolicy) tightens every eligible binding at once: a rule applies
when either the binding or the policy sets it, the maximum length is the smaller of the two, and the policy's
`approvalLifetimeMs` (one day by default) is how long a request waits for a decision.
There is one activation record per binding and person. It starts `active` (or `pending` when approval is required),
and it ends when it reaches `expiresAt`, when its holder calls `deactivate`, when an administrator calls
`revokeActivation`, or when the person leaves the group, the binding or role is deleted, or eligibility is turned
off. An activation belongs to the person, not the session: signing out does not end it. An access window on the
binding still applies while it is active.
Who may elevate and who may approve are ordinary permissions: `iam:bindings:activate` and `iam:bindings:approve` on
`iam/{roleId}`. The usual setup is a Member role holding `iam:bindings:activate`, bound to a group everyone belongs
to, and an Approver role bound to the approver group.
| Method | What it does | Access |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`activate`](#activate) | Activates an eligible binding for yourself so you hold its role for a limited time, or records an approval request when the binding requires one. | Credential |
| [`approveActivation`](#approveactivation) | Grants a pending activation request, so the requester holds the role from now until the approved duration ends. | Credential |
| [`create`](#create) | Gives a role to a person, service account, or group under your grant authority, optionally temporary, future-dated, limited to a recurring window, or eligible for just-in-time activation. | Credential |
| [`deactivate`](#deactivate) | Ends your own activation early, or withdraws your own pending request. | Credential |
| [`delete`](#delete) | Removes a binding and every activation of it, so its subject stops holding the role at the next request. | Credential |
| [`denyActivation`](#denyactivation) | Refuses a pending activation request, optionally with a note for the requester. | Credential |
| [`list`](#list) | Lists the tenant's bindings, filtered by role, subject, eligibility, or upcoming expiry. | Credential |
| [`listActivations`](#listactivations) | Lists activations and activation requests, newest first, filtered by binding, person, role, or status. | Credential |
| [`listApprovals`](#listapprovals) | Lists the pending activation requests you may decide on, oldest first, with the role and the requester. | Credential |
| [`listMine`](#listmine) | Lists your own bindings, direct and through groups, with any live activation or waiting request, so you can see what you may elevate to. | Credential |
| [`revokeActivation`](#revokeactivation) | Ends someone else's activation or pending request immediately, for incident response. | Credential |
| [`update`](#update) | Changes a binding's start, expiry, access window, or eligibility settings in place. | Credential |
## activate [#activate]
Activates an eligible binding for yourself so you hold its role for a limited time, or records an approval request when the binding requires one.
**HTTP:** `POST /api/iam/bindings/activate` (requires a credential) · **Browser client:** `client.bindings.activate()`
* **Permission:** `iam:bindings:activate` on the role (`iam/{roleId}`), from your own ordinary session of the
tenant. The binding must apply to you, directly or through a group you are a live member of.
* **Audited as:** `iam:bindings:activate`, plus `binding:activate` (with `activationId`, `roleId`, `expiresAt`, and
the justification) or, when approval is required, `binding:activation-requested`.
* **Errors:** `INVALID_TRANSITION` when the binding is not eligible, has not started, or has expired, or when it
requires approval from named approvers and none of them is active (an empty approver group and no active manager);
`ACCESS_DENIED` when the binding does not apply to you; `INVALID_INPUT` from an assumed-role session or a session
of another tenant, without a required justification, or for a `durationMs` outside one minute to the effective
maximum; `MFA_REQUIRED` when MFA is required and your session did not complete it; `IMPERSONATION_RESTRICTED` from
a "view as" session; `CONFLICT` while you already hold a live activation or a waiting request for this binding;
`INVARIANT_VIOLATION`.
`durationMs` defaults to the effective maximum: the binding's `maxActivationMs`, capped by the tenant's access
policy. Without approval, the result has `status: 'active'` and `active: true`, and the role applies from the next
request until `expiresAt`. With approval, the result has `status: 'pending'`; it lapses after the tenant's
`approvalLifetimeMs` (24 hours by default) unless someone decides. When the deployment sends email, the approver
group's live members and, with `managerApproval`, your manager receive an `activation-request`. An earlier activation that ended or was denied is replaced,
so you can ask again after a refusal.
```ts
const activation = await iam.api.bindings.activate(credential, {
tenantId,
bindingId,
durationMs: 30 * 60 * 1000,
justification: 'INC-4211: restart the payments worker',
});
if (activation.status === 'pending') {
// Waiting for an approver; bindings.listMine shows it as pendingActivation.
}
```
```ts title="Signature"
iam.api.bindings.activate(
credential: CredentialInput,
input: {
tenantId: string;
bindingId: string;
durationMs?: number;
justification?: string;
},
): Promise<{
status: 'active' | 'pending' | 'denied';
active: boolean;
bindingId: string;
identityId: string;
roleId: string;
activatedAt: number;
expiresAt: number;
justification?: string;
sessionId: string;
requestedDurationMs?: number;
decidedBy?: string;
decidedAt?: number;
note?: string;
id: string;
tenantId: string;
}>
```
## approveActivation [#approveactivation]
Grants a pending activation request, so the requester holds the role from now until the approved duration ends.
**HTTP:** `POST /api/iam/bindings/approveActivation` (requires a credential) · **Browser client:** `client.bindings.approveActivation()`
* **Permission:** `iam:bindings:approve` on the role (`iam/{roleId}`). When the binding names approvers, you must
also be a live member of its approver group, the requester's manager (with `managerApproval`), or root.
* **Audited as:** `iam:bindings:approve`, plus `binding:activation-approved` with the new `expiresAt` and your note.
* **Errors:** `INVALID_TRANSITION` (409) when the request is no longer waiting (decided, withdrawn, or lapsed) or its
binding is no longer eligible and live; `INVALID_INPUT` when you decide your own request, for a `durationMs`
outside one minute to the effective maximum, or for a note over 2048 characters; `ACCESS_DENIED` when you are not
one of the designated approvers; `IMPERSONATION_RESTRICTED` from a "view as" session; `NOT_FOUND`;
`INVARIANT_VIOLATION`.
Without `durationMs`, the requester gets the duration they asked for, capped at the binding's effective maximum as
it stands now (the tenant policy may have tightened since the request). Your `durationMs` replaces the requested
one, for example to grant a shorter window than asked. The activation starts when you approve, not when it was
requested. The requester is emailed `activation-decided` when the deployment sends email. Because nobody decides
their own request and an impersonating administrator cannot decide in someone's name, approval gives you
two-person control.
```ts
await iam.api.bindings.approveActivation(credential, {
tenantId,
activationId,
durationMs: 45 * 60 * 1000,
note: 'Approved for the change window',
});
```
```ts title="Signature"
iam.api.bindings.approveActivation(
credential: CredentialInput,
input: { tenantId: string; activationId: string; note?: string; durationMs?: number },
): Promise<{
status: 'active' | 'pending' | 'denied';
active: boolean;
bindingId: string;
identityId: string;
roleId: string;
activatedAt: number;
expiresAt: number;
justification?: string;
sessionId: string;
requestedDurationMs?: number;
decidedBy?: string;
decidedAt?: number;
note?: string;
id: string;
tenantId: string;
}>
```
## create [#create]
Gives a role to a person, service account, or group under your grant authority, optionally temporary, future-dated, limited to a recurring window, or eligible for just-in-time activation.
**HTTP:** `POST /api/iam/bindings/create` (requires a credential) · **Browser client:** `client.bindings.create()`
* **Permission:** `iam:bindings:create` on the role (`iam/{roleId}`), plus an active grant authority.
* **Audited as:** `iam:bindings:create`.
* **Errors:** `PROTECTED_RESOURCE` for the protected Owner role; `NOT_FOUND` when the role, group, approver group, or
identity is not in this tenant, or the identity is deleted; `GRANT_AUTHORITY_REQUIRED` when you hold no active
grant authority; `ACCESS_DENIED` when `authorityId` names an authority that is not yours or is revoked;
`INVALID_INPUT` for an invalid subject type, date, or window, a `startsAt` that is not before `expiresAt`, or
activation settings on a binding that is not eligible; `CONFLICT` when the subject already has a binding of this
role under the same authority; `SOD_CONFLICT` when the binding would give someone a combination of roles a
[separation-of-duties rule](/docs/guides/authorization/separation-of-duties) forbids; `INVARIANT_VIOLATION` when it
would newly break an enforced [access invariant](/docs/reference/api/invariants).
Bind roles to groups where you can: people then gain and lose the role as they join and leave, without anyone
editing bindings. The Owner role is never bound this way; ownership changes go through
[`identities.setOwner`](/docs/reference/api/identities#setowner). An expired binding that the purge worker has not
removed yet still counts for `CONFLICT`; extend it with `update` instead of creating a new one.
```ts
// A contractor gets support access on weekdays, starting next week, for 90 days.
const startsAt = Date.now() + 7 * 86_400_000;
await iam.api.bindings.create(credential, {
tenantId,
roleId: support.id,
subjectType: 'identity',
subjectId: contractor.id,
startsAt,
expiresAt: startsAt + 90 * 86_400_000,
window: { from: '09:00', to: '17:00', timeZone: 'Europe/Berlin', days: [1, 2, 3, 4, 5] },
});
// The on-call group may take the incident-responder role for up to two hours, with a reason and MFA.
await iam.api.bindings.create(credential, {
tenantId,
roleId: responder.id,
subjectType: 'group',
subjectId: onCall.id,
eligible: true,
maxActivationMs: 2 * 60 * 60 * 1000,
requireJustification: true,
requireMfa: true,
});
```
```ts title="Signature"
iam.api.bindings.create(
credential: CredentialInput,
input: BindingInput,
): Promise
```
## deactivate [#deactivate]
Ends your own activation early, or withdraws your own pending request.
**HTTP:** `POST /api/iam/bindings/deactivate` (requires a credential) · **Browser client:** `client.bindings.deactivate()`
* **Permission:** `iam:bindings:activate` on the role (`iam/{roleId}`); only the person the activation belongs to.
* **Audited as:** `iam:bindings:activate`, plus `binding:deactivate` (with `cancelled: true` for a withdrawn
request).
* **Errors:** `ACCESS_DENIED` when the activation belongs to someone else (administrators use `revokeActivation`);
`NOT_FOUND`; `INVARIANT_VIOLATION`.
Step down when the work is done early: the role stops applying at the next request. The record is deleted, so you
can activate again later.
```ts title="Signature"
iam.api.bindings.deactivate(
credential: CredentialInput,
input: { tenantId: string; activationId: string },
): Promise<{ deactivated: boolean }>
```
## delete [#delete]
Removes a binding and every activation of it, so its subject stops holding the role at the next request.
**HTTP:** `POST /api/iam/bindings/delete` (requires a credential) · **Browser client:** `client.bindings.delete()`
* **Permission:** `iam:bindings:delete` on the binding (`iam/{bindingId}`), and the binding's own grant authority
(or root).
* **Audited as:** `iam:bindings:delete`.
* **Errors:** `ACCESS_DENIED` when another administrator's authority issued the binding; `PROTECTED_RESOURCE` for an
Owner binding; `NOT_FOUND` when the binding is not in this tenant; `INVARIANT_VIOLATION` when removing it would
break an enforced invariant that expects someone to keep access.
To take a group's role away from one person, remove them from the group instead; deleting the group binding
removes the role from every member.
```ts title="Signature"
iam.api.bindings.delete(
credential: CredentialInput,
input: { tenantId: string; bindingId: string },
): Promise<{ deleted: boolean }>
```
## denyActivation [#denyactivation]
Refuses a pending activation request, optionally with a note for the requester.
**HTTP:** `POST /api/iam/bindings/denyActivation` (requires a credential) · **Browser client:** `client.bindings.denyActivation()`
* **Permission:** `iam:bindings:approve` on the role (`iam/{roleId}`), and designated-approver status when the
binding names approvers.
* **Audited as:** `iam:bindings:approve`, plus `binding:activation-denied` with your note.
* **Errors:** `INVALID_TRANSITION` (409) when the request is no longer waiting or its binding is no longer eligible
and live; `INVALID_INPUT` when you decide your own request or the note is over 2048 characters; `ACCESS_DENIED`
when you are not a designated approver; `IMPERSONATION_RESTRICTED`; `NOT_FOUND`.
The request becomes `status: 'denied'` and grants nothing. It stays visible through
`listActivations({ status: 'denied' })` until the purge worker removes it. The requester is emailed
`activation-decided` with your note when the deployment sends email, and may ask again with `activate`.
```ts title="Signature"
iam.api.bindings.denyActivation(
credential: CredentialInput,
input: { tenantId: string; activationId: string; note?: string },
): Promise<{
status: 'active' | 'pending' | 'denied';
active: boolean;
bindingId: string;
identityId: string;
roleId: string;
activatedAt: number;
expiresAt: number;
justification?: string;
sessionId: string;
requestedDurationMs?: number;
decidedBy?: string;
decidedAt?: number;
note?: string;
id: string;
tenantId: string;
}>
```
## list [#list]
Lists the tenant's bindings, filtered by role, subject, eligibility, or upcoming expiry.
**HTTP:** `POST /api/iam/bindings/list` (requires a credential) · **Browser client:** `client.bindings.list()`
* **Permission:** `iam:bindings:read` on the role (`iam/{roleId}`) when you filter by `roleId`, otherwise on the
subject (`iam/{subjectId}`) when you filter by `subjectId`, otherwise on the tenant.
* **Audited as:** `iam:bindings:read`.
* **Errors:** `INVALID_INPUT` for an unknown `subjectType`, a non-boolean `eligible`, or an invalid `expiresBefore`.
Expired bindings are left out unless you pass `includeExpired: true`; future-dated ones are included.
`eligible: true` keeps only eligible bindings and `eligible: false` only standing ones, which is how you audit
standing privileged access. `expiresBefore` keeps temporary bindings that end at or before that time, for "what
ends this month?" reports. Group bindings are returned as stored, not expanded to members: use
[`identities.listBindings`](/docs/reference/api/identities#listbindings) for one person's effective roles and
[`roles.listBindings`](/docs/reference/api/roles#listbindings) for holders with names.
```ts
const endingSoon = await iam.api.bindings.list(credential, {
tenantId,
expiresBefore: Date.now() + 14 * 86_400_000,
});
```
```ts title="Signature"
iam.api.bindings.list(
credential: CredentialInput,
input: {
tenantId: string;
roleId?: string;
subjectType?: 'identity' | 'group';
subjectId?: string;
includeExpired?: boolean;
eligible?: boolean;
expiresBefore?: number;
},
): Promise
```
## listActivations [#listactivations]
Lists activations and activation requests, newest first, filtered by binding, person, role, or status.
**HTTP:** `POST /api/iam/bindings/listActivations` (requires a credential) · **Browser client:** `client.bindings.listActivations()`
* **Permission:** `iam:bindings:read` on the first of `bindingId`, `identityId`, or `roleId` you filter by
(`iam/{id}`), otherwise on the tenant.
* **Audited as:** `iam:bindings:read`.
* **Errors:** `INVALID_INPUT` for a `status` other than `pending`, `active`, or `denied`.
Without `status`, the result is who is elevated right now: live activations only. `status: 'pending'` lists open
requests, `'active'` activations, and `'denied'` refusals (all of them, until the purge worker removes them).
`includeExpired: true` adds records that have ended or lapsed but are not yet purged. Each record carries `status`
and `active` (whether it grants at this moment).
```ts title="Signature"
iam.api.bindings.listActivations(
credential: CredentialInput,
input: {
tenantId: string;
bindingId?: string;
identityId?: string;
roleId?: string;
includeExpired?: boolean;
status?: 'pending' | 'active' | 'denied';
},
): Promise<{
status: 'active' | 'pending' | 'denied';
active: boolean;
bindingId: string;
identityId: string;
roleId: string;
activatedAt: number;
expiresAt: number;
justification?: string;
sessionId: string;
requestedDurationMs?: number;
decidedBy?: string;
decidedAt?: number;
note?: string;
id: string;
tenantId: string;
}[]>
```
## listApprovals [#listapprovals]
Lists the pending activation requests you may decide on, oldest first, with the role and the requester.
**HTTP:** `POST /api/iam/bindings/listApprovals` (requires a credential) · **Browser client:** `client.bindings.listApprovals()`
* **Permission:** `iam:bindings:approve` on the tenant. A request is included only when you also hold
`iam:bindings:approve` on its role and are a designated approver where the binding names any.
* **Audited as:** `iam:bindings:approve`.
It never includes your own requests or requests that have lapsed. Use it to build an approver's inbox; each entry
carries `role` (id and name) and `requester` (id, name, and email).
```ts title="Signature"
iam.api.bindings.listApprovals(
credential: CredentialInput,
input: { tenantId: string },
): Promise<({
status: 'active' | 'pending' | 'denied';
active: boolean;
bindingId: string;
identityId: string;
roleId: string;
activatedAt: number;
expiresAt: number;
justification?: string;
sessionId: string;
requestedDurationMs?: number;
decidedBy?: string;
decidedAt?: number;
note?: string;
id: string;
tenantId: string;
} & {
role?: { id: string; name: string };
requester?: { id: string; name: string; email?: string };
})[]>
```
## listMine [#listmine]
Lists your own bindings, direct and through groups, with any live activation or waiting request, so you can see what you may elevate to.
**HTTP:** `POST /api/iam/bindings/listMine` (requires a credential) · **Browser client:** `client.bindings.listMine()`
* **Permission:** `iam:bindings:activate` on the tenant, from an ordinary session of the tenant.
* **Audited as:** `iam:bindings:activate`.
* **Errors:** `INVALID_INPUT` from an assumed-role session or a session of another tenant.
It needs no `iam:bindings:read`, so members see their own access without seeing everyone else's. Each entry is a
binding with `via` (`'identity'`, or `{ groupId }` for a group binding) and its `role`, plus `activation` (`id`,
`activatedAt`, `expiresAt`) while one is live, `pendingActivation` (`id`, `requestedAt`, `expiresAt`) while a request
waits, and `inWindow` for bindings with an access window. Standing and eligible bindings are both listed;
future-dated ones appear with their `startsAt`, and expired ones are left out.
```ts title="Signature"
iam.api.bindings.listMine(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## revokeActivation [#revokeactivation]
Ends someone else's activation or pending request immediately, for incident response.
**HTTP:** `POST /api/iam/bindings/revokeActivation` (requires a credential) · **Browser client:** `client.bindings.revokeActivation()`
* **Permission:** `iam:bindings:delete` on the binding (`iam/{bindingId}`), and the binding's own grant authority
(or root), like deleting the binding.
* **Audited as:** `iam:bindings:delete`, plus `binding:deactivate` with `revoked: true` and the holder's
`identityId`.
* **Errors:** `ACCESS_DENIED` when another administrator's authority issued the binding; `NOT_FOUND`;
`INVARIANT_VIOLATION`.
Revoking ends the elevation, not the entitlement: the binding stays eligible and the person can activate it again.
To stop that, delete the binding or remove the person from its group. Revoking does not end the person's sessions;
use [`identities.revokeSessions`](/docs/reference/api/identities#revokesessions) for that.
```ts title="Signature"
iam.api.bindings.revokeActivation(
credential: CredentialInput,
input: { tenantId: string; activationId: string },
): Promise<{ deactivated: boolean }>
```
## update [#update]
Changes a binding's start, expiry, access window, or eligibility settings in place.
**HTTP:** `POST /api/iam/bindings/update` (requires a credential) · **Browser client:** `client.bindings.update()`
* **Permission:** `iam:bindings:create` on the role (`iam/{roleId}`), and the binding's own grant authority (or
root).
* **Audited as:** `iam:bindings:create`.
* **Errors:** `ACCESS_DENIED` ("Cannot mutate a higher authority binding") when another administrator's authority
issued the binding; `PROTECTED_RESOURCE` for an Owner binding; `INVALID_INPUT` when nothing is given to change, or
under the same date, window, and setting rules as `create`; `NOT_FOUND`; `SOD_CONFLICT` when extending an already
expired binding would create a forbidden combination; `INVARIANT_VIOLATION`.
Pass `null` to clear `startsAt`, `expiresAt`, `window`, or `approverGroupId`. The binding keeps its id and its
authority. `eligible: false` turns an eligible binding into a standing one, drops its activation settings, and ends
every activation and pending request of it; `eligible: true` does the reverse, so the subject stops holding the role
until they activate it. Editing a binding that an [access package](/docs/guides/privileged-access/access-packages)
created takes it over: revoking the package no longer removes it.
```ts
// Extend a contractor's access by 30 days and drop the business-hours limit.
await iam.api.bindings.update(credential, {
tenantId,
bindingId,
expiresAt: Date.now() + 30 * 86_400_000,
window: null,
});
```
```ts title="Signature"
iam.api.bindings.update(
credential: CredentialInput,
input: {
tenantId: string;
bindingId: string;
startsAt?: number | null;
expiresAt?: number | null;
window?: AccessWindow | null;
} & EligibilityInput,
): Promise
```
# certifications (/docs/reference/api/certifications)
> Certification campaigns turn periodic access reviews into recorded decisions.
Certification campaigns turn periodic access reviews into recorded decisions. A campaign snapshots the role bindings
under review, reviewers keep or revoke each one, and closing the campaign removes the revoked bindings and records
what happened to every item, which is the evidence auditors ask for. See the
[certifications guide](/docs/guides/governance/certifications) for the full workflow.
Campaign permissions are checked on the internal resource `iam/certifications/*` for tenant-wide calls (`create`,
`list`) and on `iam/certifications/{campaignId}` for one campaign, so you can scope who manages or reviews which
campaign.
## Reviewers and who may decide [#reviewers-and-who-may-decide]
Every item is one binding as it stood when the campaign opened. Who decides it depends on the campaign's
`reviewerMode`:
* **`named`** (the default): the people in `reviewerIds` decide every item with `decide`. When `reviewerIds` is
empty, anyone holding `iam:certifications:review` on the campaign may decide.
* **`manager`**: each person's items are assigned to their manager (`Identity.managerId`) when that manager is an
active, unexpired member of the tenant. Managers decide their items with `review`, which needs no certification
permission, and find them with `listMine`. Items without an active manager, and items held by groups, fall back to
the named reviewers (or to anyone holding `iam:certifications:review` when none are named). Through `decide`, a
manager-assigned item may be decided only by that manager or by a holder of `iam:certifications:manage`.
Nobody decides on their own access, directly or through a group they belong to (`SELF_REVIEW`). A decision can be
changed until the campaign closes. [`roleMining.reviewRecommendations`](/docs/reference/api/role-mining#reviewrecommendations)
suggests keep or revoke for every item from recorded usage and sign-in activity.
## What closing does [#what-closing-does]
Closing applies the campaign in one transaction. Items decided `revoke`, and undecided items when the campaign's
`undecided` is `revoke`, have their binding deleted under the closer's
[grant authority](/docs/guides/authorization/roles#grant-authorities); everything else is kept. Each item records an
`outcome`:
* `kept`: the binding stays.
* `revoked`: the binding was removed (with its just-in-time activations), audited as `iam:bindings:delete`.
* `already-removed`: the binding to revoke was already gone, or no longer matched the item's role and subject.
* `revocation-failed`: the closer may not remove it. A binding can be removed only by the administrator whose
authority issued it or by a root administrator, so a binding someone else granted is left for them, with the
reason in `outcomeDetail`.
A binding an [access package](/docs/guides/privileged-access/access-packages) created is removed like any other: a
manual assignment then reports `broken`, and an automatic assignment gets the binding back at the next reconcile
while the person still matches the rule. To remove birthright access for good, change the rule.
Campaigns created with `autoClose: true` and a `dueAt` are closed by the deployment job
[`iam.closeOverdueCertifications()`](/docs/reference/api#closeoverduecertifications) (CLI `close-certifications`)
once due, under the creator's authority, audited as `certification:auto-close` by `deployment-operator`.
| Method | What it does | Access |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`close`](#close) | Closes an open campaign and applies it, removing the bindings reviewers revoked. | Credential |
| [`create`](#create) | Opens a campaign over the tenant's current role bindings and notifies the reviewers. | Credential |
| [`decide`](#decide) | Records keep or revoke decisions on up to 200 items of an open campaign. | Credential |
| [`delete`](#delete) | Deletes a closed campaign and all its items. | Credential |
| [`get`](#get) | Returns one campaign with its items and progress. | Credential |
| [`list`](#list) | Lists the tenant's campaigns, newest first, each with its progress. | Credential |
| [`listMine`](#listmine) | Lists the open campaigns that have items assigned to you as a manager, with only those items. | Credential |
| [`remind`](#remind) | Emails every reviewer who still has undecided items a reminder with their pending count. | Credential |
| [`review`](#review) | Records a manager's keep or revoke decisions on up to 200 items assigned to them, without a certification permission. | Credential |
## close [#close]
Closes an open campaign and applies it, removing the bindings reviewers revoked.
**HTTP:** `POST /api/iam/certifications/close` (requires a credential) · **Browser client:** `client.certifications.close()`
* **Permission:** `iam:certifications:manage` on the campaign, with
[recent authentication](/docs/guides/authentication/sessions#recent-authentication).
* **Audited as:** `iam:certifications:manage`, plus one `iam:bindings:delete` per removed binding.
* **Errors:** `RECENT_AUTH_REQUIRED` when your sign-in is not recent or the credential is temporary;
`IMPERSONATION_RESTRICTED` from an impersonation session; `CONFLICT` when the campaign is already closed;
`NOT_FOUND` when it is not in this tenant; `INVARIANT_VIOLATION` when the removals would break an enforced
[invariant](/docs/guides/governance/change-safety).
The result is the closed campaign with `outcomes`, the number of items per outcome. Close as the administrator who
granted the bindings, or as root, to avoid `revocation-failed` items. The closed campaign keeps every decision and
outcome until you delete it.
```ts
const closed = await iam.api.certifications.close(credential, { tenantId, campaignId });
// closed.outcomes: { kept: 41, revoked: 6, 'already-removed': 1, 'revocation-failed': 0 }
```
```ts title="Signature"
iam.api.certifications.close(
credential: CredentialInput,
input: { tenantId: string; campaignId: string },
): Promise }>
```
## create [#create]
Opens a campaign over the tenant's current role bindings and notifies the reviewers.
**HTTP:** `POST /api/iam/certifications/create` (requires a credential) · **Browser client:** `client.certifications.create()`
* **Permission:** `iam:certifications:manage` on `iam/certifications/*`.
* **Audited as:** `iam:certifications:manage`.
* **Errors:** `LIMIT_EXCEEDED` (409) when more than 5000 bindings would be reviewed; `INVALID_INPUT` for an empty
name, a listed role that is unknown or protected, a `dueAt` that is not in the future, `autoClose` without `dueAt`,
or an invalid `subjectType`, `reviewerMode`, or `undecided`; `NOT_FOUND` when a reviewer is not in this tenant or
is deleted.
The campaign covers the live bindings of every non-protected role, or only of `roleIds`, optionally only those held
by people or by groups (`subjectType`). Bindings that have not started yet are left out; eligible bindings are
included and flagged. `undecided` (default `keep`) says what closing does with items nobody decided: `revoke` makes
silence mean removal. When the deployment sends email, each reviewer receives one `certification-review` message
with their own item count. The result carries the campaign, its `progress`, and `items`, the number of bindings it
covers.
```ts
const campaign = await iam.api.certifications.create(credential, {
tenantId,
name: 'Q4 admin review',
roleIds: [adminRole.id, billingAdminRole.id],
reviewerMode: 'manager',
reviewerIds: [securityLead.id], // items without an active manager go here
dueAt: Date.parse('2026-12-15T17:00:00Z'),
autoClose: true,
undecided: 'revoke',
});
```
```ts title="Signature"
iam.api.certifications.create(
credential: CredentialInput,
input: {
tenantId: string;
name: string;
roleIds?: string[];
subjectType?: 'identity' | 'group';
reviewerIds?: string[];
reviewerMode?: 'named' | 'manager';
dueAt?: number;
autoClose?: boolean;
undecided?: CertificationDecision;
},
): Promise<{
progress: CertificationProgress;
items: number;
name: string;
status: 'open' | 'closed';
createdBy: string;
createdAt: number;
dueAt?: number;
roleIds?: string[];
subjectType?: 'identity' | 'group';
reviewerIds: string[];
reviewerMode?: 'named' | 'manager';
autoClose?: boolean;
undecided: CertificationDecision;
closedAt?: number;
closedBy?: string;
id: string;
tenantId: string;
uniqueKey?: string;
}>
```
## decide [#decide]
Records keep or revoke decisions on up to 200 items of an open campaign.
**HTTP:** `POST /api/iam/certifications/decide` (requires a credential) · **Browser client:** `client.certifications.decide()`
* **Permission:** `iam:certifications:review` on the campaign; when the campaign names reviewers, you must be one of
them, except for items assigned to you as a manager.
* **Audited as:** `iam:certifications:review`.
* **Errors:** `SELF_REVIEW` for an item that certifies your own access; `ACCESS_DENIED` when you are not a reviewer
of the campaign or the item is assigned to someone else's manager and you lack `iam:certifications:manage`;
`CONFLICT` when the campaign is closed; `INVALID_INPUT` for an empty batch, more than 200 entries, or a decision
other than `keep` or `revoke`; `NOT_FOUND` for an item that is not in this campaign.
The batch is atomic: one refused entry rejects them all. Deciding an item again replaces the earlier decision and
its note. A `note` holds at most 500 characters.
```ts
await iam.api.certifications.decide(reviewerCredential, {
tenantId,
campaignId,
decisions: [
{ itemId: 'item_1', decision: 'keep' },
{ itemId: 'item_2', decision: 'revoke', note: 'Moved to finance in July' },
],
});
```
```ts title="Signature"
iam.api.certifications.decide(
credential: CredentialInput,
input: {
tenantId: string;
campaignId: string;
decisions: CertificationDecisionInput[];
},
): Promise<{ recorded: number }>
```
## delete [#delete]
Deletes a closed campaign and all its items.
**HTTP:** `POST /api/iam/certifications/delete` (requires a credential) · **Browser client:** `client.certifications.delete()`
* **Permission:** `iam:certifications:manage` on the campaign.
* **Audited as:** `iam:certifications:manage`.
* **Errors:** `CONFLICT` when the campaign is still open (close it first); `NOT_FOUND` when it is not in this tenant.
Closed campaigns are evidence; delete one only when your retention period for review records has passed.
```ts title="Signature"
iam.api.certifications.delete(
credential: CredentialInput,
input: { tenantId: string; campaignId: string },
): Promise<{ deleted: boolean }>
```
## get [#get]
Returns one campaign with its items and progress.
**HTTP:** `POST /api/iam/certifications/get` (requires a credential) · **Browser client:** `client.certifications.get()`
* **Permission:** `iam:certifications:read` on the campaign.
* **Audited as:** `iam:certifications:read`.
* **Errors:** `NOT_FOUND` when the campaign is not in this tenant.
Items are sorted by role and subject, each with its decision, reviewer, and, once closed, its outcome. `progress`
counts `total`, `decided`, `keep`, and `revoke`. `mine: true` leaves out the items that certify your own access
(directly or through a group), which you could not decide anyway.
```ts title="Signature"
iam.api.certifications.get(
credential: CredentialInput,
input: { tenantId: string; campaignId: string; mine?: boolean },
): Promise<{
progress: CertificationProgress;
items: CertificationItem[];
name: string;
status: 'open' | 'closed';
createdBy: string;
createdAt: number;
dueAt?: number;
roleIds?: string[];
subjectType?: 'identity' | 'group';
reviewerIds: string[];
reviewerMode?: 'named' | 'manager';
autoClose?: boolean;
undecided: CertificationDecision;
closedAt?: number;
closedBy?: string;
id: string;
tenantId: string;
uniqueKey?: string;
}>
```
## list [#list]
Lists the tenant's campaigns, newest first, each with its progress.
**HTTP:** `POST /api/iam/certifications/list` (requires a credential) · **Browser client:** `client.certifications.list()`
* **Permission:** `iam:certifications:read` on `iam/certifications/*`.
* **Audited as:** `iam:certifications:read`.
Pass `status: 'open'` or `'closed'` to filter.
```ts title="Signature"
iam.api.certifications.list(
credential: CredentialInput,
input: { tenantId: string; status?: 'open' | 'closed' },
): Promise<{
progress: CertificationProgress;
name: string;
status: 'open' | 'closed';
createdBy: string;
createdAt: number;
dueAt?: number;
roleIds?: string[];
subjectType?: 'identity' | 'group';
reviewerIds: string[];
reviewerMode?: 'named' | 'manager';
autoClose?: boolean;
undecided: CertificationDecision;
closedAt?: number;
closedBy?: string;
id: string;
tenantId: string;
uniqueKey?: string;
}[]>
```
## listMine [#listmine]
Lists the open campaigns that have items assigned to you as a manager, with only those items.
**HTTP:** `POST /api/iam/certifications/listMine` (requires a credential) · **Browser client:** `client.certifications.listMine()`
* **Permission:** None beyond an ordinary session of the tenant (not an assumed role or another tenant's session).
* **Audited as:** Not audited; it only reads.
* **Errors:** `ACCESS_DENIED` from a role session or a session of another tenant.
This is the data a manager's review screen needs. It returns only manager-mode assignments; named reviewers use
`get` with `mine: true` instead.
```ts title="Signature"
iam.api.certifications.listMine(
credential: CredentialInput,
input: { tenantId: string },
): Promise<{
id: string;
name: string;
dueAt?: number;
reviewerMode: 'named' | 'manager';
items: CertificationItem[];
progress: CertificationProgress;
}[]>
```
## remind [#remind]
Emails every reviewer who still has undecided items a reminder with their pending count.
**HTTP:** `POST /api/iam/certifications/remind` (requires a credential) · **Browser client:** `client.certifications.remind()`
* **Permission:** `iam:certifications:manage` on the campaign.
* **Audited as:** `iam:certifications:manage`, plus `certification:remind` with the counts.
* **Errors:** `DELIVERY_REQUIRED` when the deployment has no email delivery callback; `CONFLICT` when the campaign
is closed; `NOT_FOUND` when it is not in this tenant.
Managers are reminded of their assigned items and named reviewers of the undecided items that fall back to them.
Only active reviewers with an email address are counted. When the campaign names no reviewers, nobody is reminded of
unassigned items. Returns `reminded` (people emailed) and `pending` (undecided items). Send one a few days before
`dueAt`.
```ts title="Signature"
iam.api.certifications.remind(
credential: CredentialInput,
input: { tenantId: string; campaignId: string },
): Promise<{ reminded: number; pending: number }>
```
## review [#review]
Records a manager's keep or revoke decisions on up to 200 items assigned to them, without a certification permission.
**HTTP:** `POST /api/iam/certifications/review` (requires a credential) · **Browser client:** `client.certifications.review()`
* **Permission:** None beyond an ordinary, non-impersonated session of the tenant; every item must be assigned to
you.
* **Audited as:** `certification:review`, with the number of keep and revoke decisions.
* **Errors:** `ACCESS_DENIED` for an item that is not assigned to you, or from a role session or another tenant's
session; `IMPERSONATION_RESTRICTED` from an impersonation session; `SELF_REVIEW` for your own access; `CONFLICT`
when the campaign is closed; `INVALID_INPUT` for an empty batch, more than 200 entries, or an invalid decision.
This lets line managers take part in reviews without holding an administrator role: being assigned the item is the
authorization. Like `decide`, the batch is atomic and a later decision replaces an earlier one.
```ts
await iam.api.certifications.review(managerCredential, {
tenantId,
campaignId,
decisions: [{ itemId, decision: 'revoke', note: 'No longer on the payments team' }],
});
```
```ts title="Signature"
iam.api.certifications.review(
credential: CredentialInput,
input: {
tenantId: string;
campaignId: string;
decisions: CertificationDecisionInput[];
},
): Promise<{ recorded: number }>
```
# config (/docs/reference/api/config)
> Configuration as code: export a tenant's access model as one JSON document, review changes to it, and apply it the same way to staging and production.
Configuration as code: export a tenant's access model as one JSON document, review changes to it, and apply it
the same way to staging and production. Hand-edited roles drift between environments and nobody can say who
changed what; a document in version control gives you pull-request review, a dry run before every change, and a
pipeline check that fails when someone edits production by hand. See
[configuration as code](/docs/guides/privileged-access/config-as-code).
## The configuration document [#the-configuration-document]
A document has `version: 1` and any of these lists, each keyed by name (never by ID), so the same file works in
every environment:
| Key | What it holds |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policies` | Policies with their documents. |
| `roles` | Roles with attached policy names, an inline `permissions` list or `document`, and inherited role names. |
| `groups` | Groups, optionally with `members` as email addresses (membership is then made to match exactly). |
| `bindings` | Group role bindings, with eligibility, activation rules, approver group, and access window. |
| `resourceTypes` | Tenant-defined resource types (only with `permissions.mode: 'tenant-defined'`). |
| `packages` | Access packages with their roles, groups, request settings, and optional automatic-assignment rule. |
| `accessPolicy` | The tenant's activation floors for eligible bindings (`{}` clears them). |
| `invariants` | Access invariants, naming groups by name and people by email. |
| `agreements` | Terms-of-use agreements; a content change publishes a new version everyone accepts again. |
| `departments` | [Departments](/docs/reference/api/departments) by name, with `code`, `parent` (a name), `head` and `members` (emails), and `costCenter`. |
| `teams` | [Teams](/docs/reference/api/teams) by `slug`, with `parent` (a slug), `department` (a name), join settings, `maintainers` and `members` (emails), and the `roles` they hold as standing bindings. |
Runtime state stays out of the document: identities, their direct role bindings, credentials, webhooks, who
holds which package, temporary team memberships, and join requests. Team backing groups (`team:{slug}`) never appear
under `groups` or `bindings`: a team's access is its `roles` list. The protected Owner role and policy are never exported or changed.
## How planning and pruning work [#how-planning-and-pruning-work]
Only the kinds a document lists are compared. A kind the document leaves out is left alone, so a file with just
`roles` never touches groups. Within a listed kind, items are matched by name and reported as `create`, `update`
(with the changed `fields`, `before`, and `after`), or `unchanged`. Items of a listed kind that the document omits
are deleted only when you pass `prune: true`. References between items (a role's policies, a binding's group and
role, a package's roles) must exist in the document or in the tenant after the apply, or the plan fails with
`INVALID_INPUT` naming the problem.
| Method | What it does | Access |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`apply`](#apply) | Applies a configuration document to the tenant in one transaction, and returns the changes it made. | Credential |
| [`export`](#export) | Returns the tenant's roles, policies, groups, bindings, and the other configuration kinds as a document that `plan` and `apply` accept. | Credential |
| [`plan`](#plan) | Shows, without writing anything, every change `apply` would make for a configuration document. | Credential |
## apply [#apply]
Applies a configuration document to the tenant in one transaction, and returns the changes it made.
**HTTP:** `POST /api/iam/config/apply` (requires a credential) · **Browser client:** `client.config.apply()`
* **Permission:** `iam:config:apply` on the tenant, plus the permission of the equivalent direct call for every
change (for example `iam:roles:create` on the tenant, `iam:policies:update` on the policy,
`iam:bindings:create` on the role, `iam:groups:update` on the group, `iam:tenants:update` for the access
policy). New roles, policies, and bindings are created under your grant authority.
* **Audited as:** `iam:config:apply`, and `config:apply` with metadata `prune`, the change counts, and `changed`
(one line per change).
* **Errors:** `INVALID_INPUT` for a malformed document or an unknown reference; `INVALID_POLICY` or
`INVALID_ACTION` for a policy document storage would reject; `ACCESS_DENIED` naming the first change you are not
allowed to make; `GRANT_AUTHORITY_REQUIRED` when something must be created and you hold no grant authority;
`LIMIT_EXCEEDED` when a create exceeds the tenant's plan limits; `SOD_CONFLICT` when the result gives someone
roles a [separation-of-duties rule](/docs/guides/authorization/separation-of-duties) forbids together;
`INVARIANT_VIOLATION` when it would newly break an enforced access invariant.
Every change is authorized exactly like the direct API call, so the document can never do more than you could do
by hand, and one failure rolls everything back: the tenant is never left half-applied. Changes run in dependency
order: policies, roles, and groups are created before the bindings and packages that name them, and are deleted
only after those are gone. Changing a group binding's eligibility or activation rules replaces the binding, which
ends its current activations.
Always [`plan`](#plan) first and review the result. The `config-apply` [CLI command](/docs/reference/cli#config-apply)
runs this call from a pipeline.
```ts
const document = JSON.parse(await readFile('tenant.json', 'utf8'));
const plan = await iam.api.config.plan(credential, { tenantId, config: document });
if (plan.summary.delete === 0) {
const result = await iam.api.config.apply(credential, { tenantId, config: document });
console.log(result.summary); // { create: 2, update: 1, delete: 0, unchanged: 14 }
}
```
```ts title="Signature"
iam.api.config.apply(
credential: CredentialInput,
input: { tenantId: string; config: unknown; prune?: boolean },
): Promise<{
applied: true;
tenantId: string;
prune: boolean;
changes: ConfigChange[];
summary: Record;
}>
```
## export [#export]
Returns the tenant's roles, policies, groups, bindings, and the other configuration kinds as a document that `plan` and `apply` accept.
**HTTP:** `POST /api/iam/config/export` (requires a credential) · **Browser client:** `client.config.export()`
* **Permission:** `iam:config:read` on the tenant.
* **Audited as:** `iam:config:read`.
Use it to bootstrap version control from a tenant configured by hand, or to copy one environment's model into
another. Group members are exported as lowercase email addresses (members without an email are left out), a role
whose inline document was written as a `permissions` list is exported as that list again, and `invariants`,
`agreements`, `departments`, and `teams` appear only when the tenant has some. The `config-export` [CLI command](/docs/reference/cli#config-export) writes it to a
file.
```ts title="Signature"
iam.api.config.export(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## plan [#plan]
Shows, without writing anything, every change `apply` would make for a configuration document.
**HTTP:** `POST /api/iam/config/plan` (requires a credential) · **Browser client:** `client.config.plan()`
* **Permission:** `iam:config:read` on the tenant.
* **Audited as:** `iam:config:read`.
* **Errors:** `INVALID_INPUT` for a malformed document (wrong `version`, duplicate names, more than 1000 items in a
list) or an unknown reference.
The result lists each change with its kind, name, and action, and a `summary` with counts per action. Planning
does not check whether you may make each change; `apply` does. Run it in CI on every pull request, and use the
`config-plan` [CLI command](/docs/reference/cli#config-plan) with `--fail-on-drift` in a nightly job to catch
changes someone made by hand.
```ts title="Signature"
iam.api.config.plan(
credential: CredentialInput,
input: { tenantId: string; config: unknown; prune?: boolean },
): Promise
```
# credentials (/docs/reference/api/credentials)
> Credentials manages API keys: opaque bearer tokens that let a service account call Better IAM and your product without a person signing in.
Credentials manages API keys: opaque bearer tokens that let a [service account](/docs/reference/api/service-accounts)
call Better IAM and your product without a person signing in. Keys carry a label, record when they were last used,
can be limited to a list of actions, and always expire, so you can find and remove the ones nobody needs. People
never get API keys; they use sessions. The
[API key hygiene guide](/docs/guides/privileged-access/lifecycle#api-key-hygiene) covers the routine.
## What a key can do [#what-a-key-can-do]
A key acts as its service account: it carries the account's roles, group memberships, and relations, and policies see
`principal.kind` as `service` and `principal.sessionKind` as `api-key`. Present it as `Authorization: Bearer `
over HTTP, or as `{ token }` in server calls. Two limits apply on top of the account's grants, and both only ever
narrow access:
* **Scopes or a session policy.** `scopes: ['documents:read']` compiles into a policy that allows exactly those actions
on every resource; `policy` accepts a full [policy document](/docs/guides/authorization/policies) instead. Either one
acts as a boundary: the key can do only what both the account's roles and the policy allow.
* **The issuer's grant authority.** A key is tied to the
[grant authority](/docs/guides/authorization/roles) of the administrator who issued it and stays within that
authority's ceiling. If the authority is revoked (for example when that administrator is offboarded), the key stops
working.
A session token the key mints with [`sts.getSessionToken`](/docs/reference/api/sts#getsessiontoken) keeps both
limits. A role session it assumes with [`roles.assume`](/docs/reference/api/roles#assume) does not: the key's scopes
only decide whether it may assume the role (`iam:roles:assume`), and the role session then acts with the role's
permissions, bounded by the trust's ceiling and its own session `policy`. Scope the role or the trust, not only the
key.
A key is refused as soon as its account is disabled, expires, or is deleted. Operations that require recent
authentication, such as creating webhooks or keys, accept a key only during the first minutes after it was issued or
rotated (five minutes by default), because a key's authentication time is its creation time.
## Keeping keys clean [#keeping-keys-clean]
Every key has a `name` (at most 128 characters) and `description` for reviews, an `expiresAt` (90 days by default, at
most a year), and a `lastUsedAt` that is recorded at most once a minute and absent until first use. `list` with
`unusedForMs` finds keys nobody uses, [`analysis.findings`](/docs/reference/api/analysis#findings) reports them as
`stale-api-key`, and [`reports.access`](/docs/reference/api/reports#access) lists keys unused or ending soon. Expired
keys stay listed with `expired: true` until you renew or revoke them. Token material is never returned after
`create` and `rotate`; only a hash is stored.
| Method | What it does | Access |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`create`](#create) | Issues an API key for an active service account and returns its token, which is shown only once. | Credential |
| [`get`](#get) | Returns one API key's label, lifetime, scopes, and last use, without token material. | Credential |
| [`list`](#list) | Lists the API keys of the tenant or of one service account, newest first, optionally only the unused ones. | Credential |
| [`revoke`](#revoke) | Deletes an API key so it stops working immediately. | Credential |
| [`rotate`](#rotate) | Replaces an API key with a new token in one transaction, so the old token stops working the moment the new one exists. | Credential |
| [`update`](#update) | Relabels an API key or moves its expiry; the token itself does not change. | Credential |
## create [#create]
Issues an API key for an active service account and returns its token, which is shown only once.
**HTTP:** `POST /api/iam/credentials/create` (requires a credential) · **Browser client:** `client.credentials.create()`
* **Permission:** `iam:credentials:create` on the service account (`iam/{identityId}`), with recent authentication and
an active grant authority.
* **Audited as:** `iam:credentials:create`, on the service account.
* **Errors:** `INVALID_IDENTITY` when the identity is not an active, unexpired service account; `NOT_FOUND` when it is
not in this tenant; `INVALID_INPUT` for both `scopes` and `policy`, an empty `scopes` list, or `expiresInSeconds`
outside 60 seconds to 365 days; `INVALID_ACTION` or `INVALID_POLICY` when the scopes or policy do not validate
against the catalog; `GRANT_AUTHORITY_REQUIRED` when the caller holds no grant authority; `RECENT_AUTH_REQUIRED`;
`IMPERSONATION_RESTRICTED`.
Because the permission is checked on the service account, you can let a team issue keys for its own integration
accounts only. Give every key the narrowest scopes that work and a name that says where it is deployed.
New keys are 58 characters that start with `biam_key_` and end in a checksum, so secret scanners (and the
`credentialTokenScanPattern` export of `@better-iam/auth`) can recognize a leaked one; keys issued before the format
existed keep working until they expire. For short-lived, narrower credentials derived from a key, such as one per CI
job, use [`sts.getSessionToken`](/docs/reference/api/sts#getsessiontoken) with the key as the caller.
```ts
const { token, credentialId, expiresAt } = await iam.api.credentials.create(credential, {
tenantId,
identityId: deployBotId,
name: 'github-actions',
description: 'Release workflow in acme/api',
scopes: ['deployments:create', 'deployments:read'],
expiresInSeconds: 30 * 86400,
});
// Store `token` in the CI secret store now; it cannot be read again.
```
```ts title="Signature"
iam.api.credentials.create(
credential: CredentialInput,
input: {
tenantId: string;
identityId: string;
expiresInSeconds?: number;
policy?: PolicyDocument;
scopes?: string[];
name?: string;
description?: string;
},
): Promise<{
token: string;
credentialId: string;
expiresAt: number;
name: string | undefined;
}>
```
## get [#get]
Returns one API key's label, lifetime, scopes, and last use, without token material.
**HTTP:** `POST /api/iam/credentials/get` (requires a credential) · **Browser client:** `client.credentials.get()`
* **Permission:** `iam:credentials:read` on the key.
* **Audited as:** `iam:credentials:read`.
* **Errors:** `NOT_FOUND` when the key is not in this tenant; `INVALID_CREDENTIAL` when the id belongs to a session
that is not an API key.
`scopes` is present when the key was issued with `scopes` (its policy is exactly that list); otherwise `policy` shows
the session policy, if any. `credentialAuthorityId` names the grant authority the key was issued under.
```ts title="Signature"
iam.api.credentials.get(
credential: CredentialInput,
input: { tenantId: string; credentialId: string },
): Promise
```
## list [#list]
Lists the API keys of the tenant or of one service account, newest first, optionally only the unused ones.
**HTTP:** `POST /api/iam/credentials/list` (requires a credential) · **Browser client:** `client.credentials.list()`
* **Permission:** `iam:credentials:read` on the service account when `identityId` is given, otherwise on the tenant.
* **Audited as:** `iam:credentials:read`.
* **Errors:** `INVALID_INPUT` when `unusedForMs` is negative or more than ten years.
`unusedForMs` keeps only keys that have not authenticated a request in that long, counting keys never used since
they were issued. Expired keys are included, marked `expired: true`.
```ts
const stale = await iam.api.credentials.list(credential, { tenantId, unusedForMs: 60 * 86_400_000 });
for (const key of stale)
await iam.api.credentials.revoke(credential, { tenantId, credentialId: key.id });
```
```ts title="Signature"
iam.api.credentials.list(
credential: CredentialInput,
input: { tenantId: string; identityId?: string; unusedForMs?: number },
): Promise
```
## revoke [#revoke]
Deletes an API key so it stops working immediately.
**HTTP:** `POST /api/iam/credentials/revoke` (requires a credential) · **Browser client:** `client.credentials.revoke()`
* **Permission:** `iam:credentials:revoke` on the key, with recent authentication.
* **Audited as:** `iam:credentials:revoke`.
* **Errors:** `NOT_FOUND` when the key is not in this tenant; `INVALID_CREDENTIAL` for any other kind of session
(user sessions and role sessions end through their own calls); `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`.
Revocation cannot be undone; issue a new key if the integration still needs access. To stop every key of an account
at once, disable the account with [`serviceAccounts.setStatus`](/docs/reference/api/service-accounts#setstatus).
```ts title="Signature"
iam.api.credentials.revoke(
credential: CredentialInput,
input: { tenantId: string; credentialId: string },
): Promise<{ revoked: boolean }>
```
## rotate [#rotate]
Replaces an API key with a new token in one transaction, so the old token stops working the moment the new one exists.
**HTTP:** `POST /api/iam/credentials/rotate` (requires a credential) · **Browser client:** `client.credentials.rotate()`
* **Permission:** `iam:credentials:create` on the key, with recent authentication.
* **Audited as:** `iam:credentials:create`.
* **Errors:** `NOT_FOUND` when the key is not in this tenant; `INVALID_CREDENTIAL` when the id is not an API key;
`ACCESS_DENIED` unless the caller issued the key (holds its grant authority) or is the platform root, or when that
authority has been revoked; `RECENT_AUTH_REQUIRED`; `IMPERSONATION_RESTRICTED`.
The replacement gets a new `credentialId` and keeps the name, description, scopes or policy, and expiry of the old
key. Its usage history starts over, so it shows as unused until the integration uses it. Rotate on a schedule, or at
once when a token may have leaked; deploy the returned token before anything else, because the old one is already
dead.
```ts title="Signature"
iam.api.credentials.rotate(
credential: CredentialInput,
input: { tenantId: string; credentialId: string },
): Promise<{
token: string;
credentialId: string;
expiresAt: number;
name: string | undefined;
}>
```
## update [#update]
Relabels an API key or moves its expiry; the token itself does not change.
**HTTP:** `POST /api/iam/credentials/update` (requires a credential) · **Browser client:** `client.credentials.update()`
* **Permission:** `iam:credentials:create` on the key; changing `expiresAt` also requires recent authentication and
the key's grant authority (the issuer, or the platform root).
* **Audited as:** `iam:credentials:create`.
* **Errors:** `INVALID_INPUT` when nothing is given to change, or `expiresAt` is not in the future or is more than a
year away; `NOT_FOUND`; `INVALID_CREDENTIAL`; `ACCESS_DENIED` when changing the expiry of a key issued under
another administrator's authority; `RECENT_AUTH_REQUIRED` when changing the expiry without recent authentication.
Pass `null` for `name` or `description` to clear it. `expiresAt` can shorten a key's life or extend it, including
renewing a key that has already expired, which then works again without a new token.
```ts title="Signature"
iam.api.credentials.update(
credential: CredentialInput,
input: {
tenantId: string;
credentialId: string;
name?: string | null;
description?: string | null;
expiresAt?: number;
},
): Promise
```
# delegations (/docs/reference/api/delegations)
> A delegation lets one AI agent act for one person, within a scope and for a limited time.
A delegation lets one AI agent act for one person, within a scope and for a limited time. The person grants it
directly (`grant`) or approves the agent's request (`request`, then `approve` or `deny`). The agent then exchanges its
own API key for short delegated sessions (`assume`) whose identity is the person, so every decision uses the person's
own grants and never more. Agents themselves are managed with [agents](/docs/reference/api/agents). The repository
guide is `docs/agents.md`.
## What a delegated session may do [#what-a-delegated-session-may-do]
A delegated session (a `biam_dlg_…` bearer token of session kind `delegated`) acts as the person, and three ceilings
narrow what the person could do:
1. the delegation's scope: `scopes`, a list of actions (wildcards allowed) allowed on every resource, or a `policy`
document;
2. the agent's `boundary`;
3. the optional scope-down `policy` the agent passed to `assume`.
The limits of the agent key that opened the session also apply: its `scopes` (or session policy) and its issuer's
authority.
A delegation may also hold some actions back for the person to confirm one call at a time: `confirm`, a list of action
patterns accepted by `grant`, `request`, and `approve` (an empty list on `approve` drops them). Those actions are
refused until the person approves that action on that resource ([`requestConfirmation`](#requestconfirmation),
[`decideConfirmation`](#decideconfirmation)).
When the person allows it (`handoff` on `grant`, `request`, or `approve`), the agent may hand part of the delegation on
to another agent with [`handoff`](#handoff); see [Hand-offs](#hand-offs).
Delegated sessions never count as a recent sign-in, cannot manage delegations (apart from `handoff`) or open further
delegated sessions, cannot mint session tokens (`CREDENTIAL_CHAINING_DISABLED`), and cannot obtain assertions. Every use re-validates the
chain: the delegation (active, unexpired, not revoked since the session began), the person, the agent and its sponsor,
the agent's `delegable` setting, and the agent key the session came from. When any link breaks, the session is refused
with `UNAUTHENTICATED`. Policies see `principal.delegated`, `principal.delegationId`, and the agent's keys
([principal keys](/docs/guides/authorization/conditions#principal-keys)), and audit events recorded in the session name
the person as actor with `agentId` and `delegationId` in their session context.
## Lifecycle and who may act [#lifecycle-and-who-may-act]
| Status | Meaning |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending` | The agent asked with `request` and the person has not decided. `expiresAt` is when the request lapses, seven days after it was made. |
| `active` | The agent may open delegated sessions until `expiresAt`. |
| `denied` | The person turned the request down. |
| `revoked` | Ended early by the person, the agent, its sponsor, or an administrator, or because the person was offboarded or deleted or the agent deleted. |
A lapsed request or an ended delegation keeps its status and reads `expired: true`. One pending or active delegation
may link an agent and a person at a time.
The person decides in their own signed-in session (`grant`, `approve`, `deny`); the agent acts with its own API key
(`request`, `assume`). The people involved (the person, the agent with its own key, and the agent's sponsor) read,
follow (`activity`), and revoke a delegation without any permission (for a hand-off, so does the agent that handed it
on, in its delegated session); anyone else needs `iam:delegations:read` or
`iam:delegations:revoke` on `iam/{delegationId}`, and `list` needs `iam:delegations:read` on the tenant.
## Spending caps [#spending-caps]
`spend: { period, maxCostUsd?, maxTokens?, maxRequests? }` on `grant`, `request`, `approve` (where `null` removes it),
or `handoff` caps the AI model calls made under the delegation per `minute`, `hour`, `day`, or `month`, with at least
one limit. Every [inference](/docs/reference/api/inference) call in a delegated session of the delegation, or of a
hand-off below it, counts like a budget: past the cap, calls are refused with `BUDGET_EXCEEDED` until the window
resets, and the first refusal is audited as `inference:budget-exceeded` on `delegation:{delegationId}`. Summaries report
the cap and what the current window used as `spend` (`maxCostUsd`, `maxTokens`, `maxRequests`, `usedCostUsd`,
`usedTokens`, `usedRequests`, `resetsAt`). An invalid setting (another period, no limit, or a limit out of range) is
`INVALID_INPUT`.
## Hand-offs [#hand-offs]
A hand-off is a delegation an agent creates from its own, for another agent, while acting for the person: `requestedBy`
is `handoff`, `parentId` names the delegation above, and `chain` lists the agents above it (the person's own delegate
first). The person allows hand-offs with `handoff: { agents?, depth? }`: `agents` limits them to named agents of the
tenant (at most 20; any agent that accepts delegation when left out), and `depth` (1 to 3, 1 by default) is how many
hand-offs may follow one another. Each hand-off gets the depth left, and the person's `confirm` list travels down with
it.
A hand-off never allows more than its own scope, every delegation above it, the ceilings of every agent in the chain,
and the limits of the session that handed it on (its scope-down policy, its key's scopes, and that key's issuer). Every
use checks the whole chain, so a hand-off stops working when a delegation above it ends, an agent above it is
suspended, loses its sponsor, or stops accepting delegation, or the API key the handing agent acted with is revoked or
expires (a hand-off never lasts past that key). Revoking a delegation revokes the hand-offs below it; when an agent
revokes one from its delegated session, `revokedBy` names the agent. The handing agents' own inference budgets count a
hand-off's model calls too.
Hand-offs do not count toward the rule that one pending or active delegation may link an agent and a person. Policies
see the chain as `principal.delegationChain`.
| Method | What it does | Access |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`activity`](#activity) | Returns what happened under a delegation, newest first: its lifecycle and everything the agent did for the person. | Credential |
| [`approve`](#approve) | Approves an agent's pending request, optionally with a different scope, lifetime, or session cap. | Credential |
| [`assume`](#assume) | Opens a delegated session in which the agent acts for the person, and returns its bearer token once. | Credential |
| [`decideConfirmation`](#decideconfirmation) | Approves or rejects an agent's pending confirmation request. | Credential |
| [`deny`](#deny) | Turns down an agent's pending request. | Credential |
| [`get`](#get) | Returns one delegation to the person, the agent, or the agent's sponsor, or to an administrator. | Credential |
| [`getConfirmation`](#getconfirmation) | Returns one confirmation request to the person it asks or to the agent that made it. | Credential |
| [`grant`](#grant) | Lets an agent act for you, within a scope and for a limited time. | Credential |
| [`handoff`](#handoff) | Hands part of the delegation an agent acts under on to another agent, for the same person. | Credential |
| [`issueToken`](#issuetoken) | Gives an agent acting for a person a delegation token: a short-lived signed JWT that shows one service outside Better IAM that the agent acts for that person. | Credential |
| [`list`](#list) | Lists the tenant's delegations, newest first, filtered by agent, person, or status. | Credential |
| [`listConfirmations`](#listconfirmations) | Lists confirmation requests newest first: those addressed to the caller, or those the calling agent made. | Credential |
| [`listMine`](#listmine) | Returns your own delegations, newest first: the agents acting or asking to act for you, or, for an agent, the people it acts for. | Credential |
| [`request`](#request) | Asks a person to delegate to the calling agent, and emails them the request when the deployment sends email. | Credential |
| [`requestConfirmation`](#requestconfirmation) | Asks the person an agent acts for to confirm one action the delegation holds back. | Credential |
| [`revoke`](#revoke) | Ends a pending or active delegation and deletes its live delegated sessions at once. | Credential |
## activity [#activity]
Returns what happened under a delegation, newest first: its lifecycle and everything the agent did for the person.
**HTTP:** `POST /api/iam/delegations/activity` (requires a credential) · **Browser client:** `client.delegations.activity()`
* **Permission:** None for the person, the agent with its own key, or the agent's sponsor; anyone else needs
`iam:delegations:read` on the delegation.
* **Audited as:** `iam:delegations:read` when read with the permission; not audited for the people involved.
* **Errors:** `NOT_FOUND` when the delegation is not in this tenant (for a caller with the permission);
`ACCESS_DENIED` for anyone else; `INVALID_INPUT` for a `limit` outside 1 to 500 or a malformed `offset`, `from`, or
`to`.
The events are those whose resource is the delegation (`delegation:grant`, `delegation:request`,
`delegation:approve`, `delegation:deny`, `delegation:assume`, `delegation:revoke`) and those recorded for the agent's
delegated sessions under it (`sessionContext.delegationId`), allowed and denied, together with the same for every
[hand-off](#hand-offs) below it. It answers the person's question "what did the agent do on my behalf?". Page with `limit` (100 by default) and `offset`, and bound the time with `from`
and `to` (epoch milliseconds).
```ts
const trail = await iam.api.delegations.activity(aliceSession, { tenantId, delegationId });
```
```ts title="Signature"
iam.api.delegations.activity(
credential: CredentialInput,
input: ActivityQuery & { tenantId: string; delegationId: string },
): Promise
```
## approve [#approve]
Approves an agent's pending request, optionally with a different scope, lifetime, or session cap.
**HTTP:** `POST /api/iam/delegations/approve` (requires a credential) · **Browser client:** `client.delegations.approve()`
* **Permission:** The person the request names, in their own signed-in session with a recent sign-in.
* **Audited as:** `delegation:approve`, with the agent and the new `expiresAt`.
* **Errors:** `NOT_FOUND` when the request is not addressed to you (administrators included); `INVALID_TRANSITION`
(409) when it is no longer pending or has lapsed; `RECENT_AUTH_REQUIRED` without a recent sign-in;
`IMPERSONATION_RESTRICTED` from an impersonation session; `DELEGATION_NOT_ALLOWED` or `INVALID_IDENTITY` (409) when
the agent stopped accepting delegation or is no longer in good standing; `INVALID_INPUT`, `INVALID_ACTION`, or
`INVALID_POLICY` for a new scope or an out-of-range lifetime.
Pass `scopes` or `policy` (not both) to replace the scope the agent asked for, usually to narrow it; without either,
the requested scope stands. The delegation lasts `expiresInSeconds` from now (300 seconds to one year), by default as
long as the agent asked for (`requestedSeconds`). `maxSessionSeconds` (60 to 43200) caps each delegated session.
`handoff` lets the agent hand work on ([Hand-offs](#hand-offs)). An agent's requested `handoff` counts only when you
state `handoff` here yourself; a plain approval (or `null`) leaves hand-offs out.
```ts
await iam.api.delegations.approve(aliceSession, {
tenantId,
delegationId: request.id,
scopes: ['calendar:read'],
});
```
```ts title="Signature"
iam.api.delegations.approve(
credential: CredentialInput,
input: DelegationScopeInput & {
tenantId: string;
delegationId: string;
expiresInSeconds?: number;
maxSessionSeconds?: number;
},
): Promise
```
## assume [#assume]
Opens a delegated session in which the agent acts for the person, and returns its bearer token once.
**HTTP:** `POST /api/iam/delegations/assume` (requires a credential) · **Browser client:** `client.delegations.assume()`
* **Permission:** The delegation's agent, with its own API key.
* **Audited as:** `delegation:assume`, with the person, the new session's id, and `durationSeconds`.
* **Errors:** `ACCESS_DENIED` for any other credential (a delegated token cannot open another session); `NOT_FOUND`
when the delegation is not this agent's; `DELEGATION_PENDING` (409) while the person has not decided;
`DELEGATION_INACTIVE` (403) when the delegation was denied, revoked, lapsed, or ended, or the person is no longer
active; `DELEGATION_NOT_ALLOWED` (403) when the agent's `delegable` is off; `LIMIT_EXCEEDED` (409) when the
delegation already holds 20 live sessions; `INVALID_INPUT` for a `durationSeconds` out of range or a malformed
`sessionName`; `INVALID_POLICY` or `INVALID_ACTION` for the scope-down `policy`.
The token's identity is the person (`session.identityId`); `session.agentId` and `session.delegationId` name the agent
and the delegation. It lasts `durationSeconds`, from 60 up to the smaller of the agent's `maxDelegatedSessionSeconds`
(3600 when unset) and the delegation's `maxSessionSeconds`, and by default 900 seconds or that limit if it is lower.
It never outlives the delegation or the agent key that opened it. `sessionName` (2 to 64 letters, digits, or `+=,.@_-`)
appears in the session context of audit events. Each call also records `lastUsedAt` on the delegation.
```ts
const { token, expiresAt, session } = await iam.api.delegations.assume(agentKey, {
tenantId,
delegationId,
durationSeconds: 600,
sessionName: 'triage-run-42',
});
await iam.require({ token, tenantId, action: 'tickets:update', resource: { type: 'ticket', id: 'T-1' } });
```
```ts title="Signature"
iam.api.delegations.assume(
credential: CredentialInput,
input: {
tenantId: string;
delegationId: string;
durationSeconds?: number;
sessionName?: string;
policy?: PolicyDocument;
},
): Promise
```
## decideConfirmation [#decideconfirmation]
Approves or rejects an agent's pending confirmation request.
**HTTP:** `POST /api/iam/delegations/decideConfirmation` (requires a credential) · **Browser client:** `client.delegations.decideConfirmation()`
* **Permission:** None beyond the person's own session (not impersonated) of the tenant; only the person the request
asks may decide it.
* **Audited as:** `delegation:confirm` or `delegation:reject`, on the delegation, with the confirmation id, the
action, and the resource.
* **Errors:** `NOT_FOUND` when the request does not exist or asks someone else; `INVALID_TRANSITION` (409) when it
was already decided or has lapsed; `DELEGATION_INACTIVE` (403) when the delegation ended meanwhile;
`ACCESS_DENIED` from any other credential; `INVALID_INPUT` when `approve` is not a boolean.
`approve: true` opens the requested action on the requested resource, for the agent acting for the person under this
delegation, until `validSeconds` after the decision. `approve: false` rejects it; nothing opens. No recent sign-in is
needed, so the person can answer from a notification right away.
```ts title="Signature"
iam.api.delegations.decideConfirmation(
credential: CredentialInput,
input: { tenantId: string; confirmationId: string; approve: boolean },
): Promise
```
## deny [#deny]
Turns down an agent's pending request.
**HTTP:** `POST /api/iam/delegations/deny` (requires a credential) · **Browser client:** `client.delegations.deny()`
* **Permission:** The person the request names, in their own signed-in session.
* **Audited as:** `delegation:deny`, with the agent.
* **Errors:** `NOT_FOUND` when the request is not addressed to you; `INVALID_TRANSITION` (409) when it is no longer
pending or has lapsed; `IMPERSONATION_RESTRICTED` from an impersonation session.
No recent sign-in is needed. The request becomes `denied`; the agent sees that when it polls `get`, and may ask again
with a new `request`.
```ts title="Signature"
iam.api.delegations.deny(
credential: CredentialInput,
input: { tenantId: string; delegationId: string },
): Promise
```
## get [#get]
Returns one delegation to the person, the agent, or the agent's sponsor, or to an administrator.
**HTTP:** `POST /api/iam/delegations/get` (requires a credential) · **Browser client:** `client.delegations.get()`
* **Permission:** None for the person, the agent (with its own key), or the agent's sponsor; anyone else needs
`iam:delegations:read` on the delegation.
* **Audited as:** Not audited for the parties involved; `iam:delegations:read` for administrators.
* **Errors:** `NOT_FOUND` when the delegation is not in this tenant.
An agent that asked with `request` polls this until `status` leaves `pending`. The summary names the agent (with its
model and provider) and the person, the scope as `scopes` (when given as actions) and as the compiled `policy`, who
created it (`requestedBy`), the agent's `reason`, and its times; `expired` is true for a lapsed request or an active
delegation past its end. It never contains session tokens.
```ts title="Signature"
iam.api.delegations.get(
credential: CredentialInput,
input: { tenantId: string; delegationId: string },
): Promise
```
## getConfirmation [#getconfirmation]
Returns one confirmation request to the person it asks or to the agent that made it.
**HTTP:** `POST /api/iam/delegations/getConfirmation` (requires a credential) · **Browser client:** `client.delegations.getConfirmation()`
* **Permission:** None: the person's own session, the agent's own key, or a delegated session of the same delegation.
* **Audited as:** Not audited; it only reads.
* **Errors:** `NOT_FOUND` when the request does not exist or is not visible to the caller; `ACCESS_DENIED` for any
other credential.
Agents poll it while `status` is `pending`. `expired` is true for a pending request past its decision window and for
an approval past its validity.
```ts title="Signature"
iam.api.delegations.getConfirmation(
credential: CredentialInput,
input: { tenantId: string; confirmationId: string },
): Promise
```
## grant [#grant]
Lets an agent act for you, within a scope and for a limited time.
**HTTP:** `POST /api/iam/delegations/grant` (requires a credential) · **Browser client:** `client.delegations.grant()`
* **Permission:** Your own signed-in session of the tenant with a recent sign-in; no `iam:*` permission.
* **Audited as:** `delegation:grant`, with the agent, `expiresAt`, and the scopes.
* **Errors:** `DELEGATION_EXISTS` (409) when a pending request or an active delegation already links you and the agent;
`DELEGATION_NOT_ALLOWED` (403) when the agent does not accept delegation; `INVALID_IDENTITY` (409) when the agent is
not in good standing; `NOT_FOUND` when the id is not an agent of this tenant; `RECENT_AUTH_REQUIRED` without a
recent sign-in; `IMPERSONATION_RESTRICTED` from an impersonation session; `ACCESS_DENIED` from an API key, role
session, session token, or delegated session; `INVALID_INPUT` for a missing or doubled scope or an out-of-range
lifetime; `INVALID_ACTION` or `INVALID_POLICY` for a scope the catalog does not accept.
Give exactly one of `scopes` (actions, wildcards allowed) or `policy` (a policy document). Either way the scope only
narrows: the agent acts with your grants, so it can never do more than you can. `expiresInSeconds` runs from 300
seconds to one year (30 days by default), and `maxSessionSeconds` (60 to 43200) caps each delegated session below the
agent's own limit. When the agent has already asked, approve its request instead. [`agents.catalog`](/docs/reference/api/agents#catalog)
lists the agents you can delegate to.
```ts
const delegation = await iam.api.delegations.grant(aliceSession, {
tenantId,
agentId,
scopes: ['tickets:read', 'tickets:update'],
expiresInSeconds: 30 * 86_400,
maxSessionSeconds: 900,
});
```
`handoff` lets the agent hand parts of the delegation on to other agents ([Hand-offs](#hand-offs)); an invalid setting
(an unknown agent, more than 20 agents, or a depth outside 1 to 3) is `INVALID_INPUT`.
```ts title="Signature"
iam.api.delegations.grant(
credential: CredentialInput,
input: DelegationScopeInput & {
tenantId: string;
agentId: string;
expiresInSeconds?: number;
maxSessionSeconds?: number;
},
): Promise
```
## handoff [#handoff]
Hands part of the delegation an agent acts under on to another agent, for the same person.
**HTTP:** `POST /api/iam/delegations/handoff` (requires a credential) · **Browser client:** `client.delegations.handoff()`
* **Permission:** A delegated session (from `assume`) whose delegation allows hand-offs to that agent; no `iam:*`
permission.
* **Audited as:** `delegation:handoff` on the new delegation, with `parentId`, `fromAgentId`, `toAgentId`, `depth`,
and the scopes; the actor is the person, with the handing agent in the session context.
* **Errors:** `ACCESS_DENIED` for any credential but a delegated session of the tenant; `DELEGATION_INACTIVE` (403)
when the delegation (or one above it) has ended; `DELEGATION_NOT_ALLOWED` (403) when the person did not allow
hand-offs, not to that agent, or no more of them down the line, when the agent is already in the chain, or when it
does not accept delegation; `INVALID_IDENTITY` (409) when it is not in good standing; `NOT_FOUND` when the id is not
an agent of this tenant; `LIMIT_EXCEEDED` (409) when the delegation already holds 20 live hand-offs; `INVALID_INPUT`,
`INVALID_ACTION`, or `INVALID_POLICY` for the scope, `confirm`, or an `expiresInSeconds` outside 60 seconds to one
year.
Give `agentId` and exactly one of `scopes` or `policy`. The result is a new active delegation from the same person to
that agent (see [Hand-offs](#hand-offs) for what bounds it). It lasts `expiresInSeconds` (one hour by default), never
past the delegation above. It keeps the person's `confirm` list plus any `confirm` given here, and `maxSessionSeconds`
never above the delegation above. `reason` (up to 1024 characters) is shown to the person. Pass the new delegation's
`id` to the other agent, for example in an A2A message; it opens its own sessions for the person with `assume` and its
own key.
```ts
const handoff = await iam.api.delegations.handoff(
{ token: delegatedToken },
{ tenantId, agentId: researcherId, scopes: ['documents:read'], reason: 'Find sources for the report' },
);
```
```ts title="Signature"
iam.api.delegations.handoff(
credential: CredentialInput,
input: Omit & {
tenantId: string;
agentId: string;
expiresInSeconds?: number;
maxSessionSeconds?: number;
reason?: string;
},
): Promise
```
## issueToken [#issuetoken]
Gives an agent acting for a person a delegation token: a short-lived signed JWT that shows one service outside Better IAM that the agent acts for that person.
**HTTP:** `POST /api/iam/delegations/issueToken` (requires a credential) · **Browser client:** `client.delegations.issueToken()`
* **Permission:** A delegated session (from `assume`); no `iam:*` permission. The deployment needs the `a2a` option,
because its card keys sign the token.
* **Audited as:** `delegation:token-issue` on the delegation, with the `audience`, `tokenId`, `expiresAt`, and the
scopes; the actor is the person, with the agent in the session context.
* **Errors:** `FEATURE_DISABLED` (403) without the `a2a` option; `ACCESS_DENIED` for any credential but a delegated
session of the tenant; `DELEGATION_INACTIVE` (403) when the delegation or one above it has ended;
`DELEGATION_NOT_ALLOWED` (403) when:
* an agent in the chain does not list the audience in its `tokenAudiences`;
* some limit on the session does not allow a scope outright;
* a scope is one the person confirms call by call;
* a deny among the person's own grants could touch a scope.
`INVALID_INPUT` for an audience that is not an http(s) URL or other absolute URI, that has user info, a query or a
fragment, or that contains `*`; for a malformed scope (letters, digits, `:_./-`, and `*`; at most 50); or for a
`lifetimeSeconds` outside 30 to 3600. `RATE_LIMITED` (429) past the per-delegation budget.
Give `audience`, the one service the token is for. It must match the
[`tokenAudiences`](/docs/reference/api/agents#profile-and-ceiling) of the agent and of every agent that handed the work
to it. `scopes` defaults to the delegation's own scopes (none for a delegation given as a policy). Each scope must be
allowed outright, on every resource and without conditions, by every limit the session is under: the delegation and
those above it, the agents' ceilings, the session's scope-down policy, its key's scopes and issuer, and the person's
and tenant's boundaries. No scope may be an action the person confirms call by call (`confirm` anywhere in the
chain), nor one a deny statement among the person's own grants could touch. The token lasts `lifetimeSeconds` (300 by
default), never past the session or any delegation in the chain, and is recorded until it expires so a live check can
re-examine it.
The result is `{ token, tokenType: 'biam-delegation+jwt', tokenId, issuer, audience, scopes, chain, expiresAt,
expiresIn }`. The JWT's header is `{ alg, kid, typ: 'biam-delegation+jwt', jku }`. Its claims are `iss`, `sub` (the
person), `aud`, `iat`, `nbf`, `exp`, `jti`, `tenant_id`, `delegation_id`, `act`, and `scope` (space-separated, when
there are scopes). `act` is the agent (`{ sub }`), with the agents that handed the work on nested inside
(RFC 8693 section 4.1). Services verify it with `verifyDelegationToken` from `@better-iam/a2a` against the deployment's
card keys. A service next to the deployment can use `iam.a2a.verifyDelegationToken(token, { audience, live: true })`.
It re-checks the delegation chain, the person, the agents and the acting agent's key, the audience, and the scopes
against the current state, so a revocation or a narrowed limit takes effect before the token expires.
```ts
const { token } = await iam.api.delegations.issueToken(
{ token: delegatedToken },
{ tenantId, audience: 'https://api.calendar.example', scopes: ['calendar:read'] },
);
```
```ts title="Signature"
iam.api.delegations.issueToken(
credential: CredentialInput,
input: {
tenantId: string;
audience: string;
scopes?: string[];
lifetimeSeconds?: number;
},
): Promise
```
## list [#list]
Lists the tenant's delegations, newest first, filtered by agent, person, or status.
**HTTP:** `POST /api/iam/delegations/list` (requires a credential) · **Browser client:** `client.delegations.list()`
* **Permission:** `iam:delegations:read` on the tenant.
* **Audited as:** `iam:delegations:read`.
`status` is `pending`, `active`, `denied`, or `revoked`; lapsed requests and ended delegations keep their status and
carry `expired: true`.
```ts title="Signature"
iam.api.delegations.list(
credential: CredentialInput,
input: {
tenantId: string;
agentId?: string;
subjectId?: string;
status?: Delegation['status'];
},
): Promise
```
## listConfirmations [#listconfirmations]
Lists confirmation requests newest first: those addressed to the caller, or those the calling agent made.
**HTTP:** `POST /api/iam/delegations/listConfirmations` (requires a credential) · **Browser client:** `client.delegations.listConfirmations()`
* **Permission:** None: a person's own session sees the requests that ask them, an agent's own key every request it
made, and a delegated session those of its delegation.
* **Audited as:** Not audited; it only reads.
* **Errors:** `ACCESS_DENIED` for any other credential or tenant.
Filter with `status` (`pending`, `approved`, or `rejected`). It backs an "actions waiting for your confirmation" list.
```ts title="Signature"
iam.api.delegations.listConfirmations(
credential: CredentialInput,
input: { tenantId: string; status?: DelegationConfirmation['status'] },
): Promise
```
## listMine [#listmine]
Returns your own delegations, newest first: the agents acting or asking to act for you, or, for an agent, the people it acts for.
**HTTP:** `POST /api/iam/delegations/listMine` (requires a credential) · **Browser client:** `client.delegations.listMine()`
* **Permission:** A person's own signed-in session, or an agent's own API key, in the tenant.
* **Audited as:** Not audited; it only reads.
* **Errors:** `ACCESS_DENIED` for other credentials (role sessions, session tokens, delegated sessions, a session of
another tenant); `IMPERSONATION_RESTRICTED` from an impersonation session.
Every status is included, so a settings page can show requests to decide, delegations to revoke, and the history.
```ts title="Signature"
iam.api.delegations.listMine(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## request [#request]
Asks a person to delegate to the calling agent, and emails them the request when the deployment sends email.
**HTTP:** `POST /api/iam/delegations/request` (requires a credential) · **Browser client:** `client.delegations.request()`
* **Permission:** The agent's own API key.
* **Audited as:** `delegation:request`, with the person and the scopes.
* **Errors:** `ACCESS_DENIED` for any credential but an agent's own key; `RATE_LIMITED` (429) when the agent asks too
often; `NOT_FOUND` when no active person of the tenant has that id or email; `DELEGATION_EXISTS` (409) when a pending
request or an active delegation already links the agent and the person; `DELEGATION_NOT_ALLOWED` when the agent's
`delegable` is off; `INVALID_INPUT` without exactly one of `subjectId` and `subjectEmail`, without a `reason`, or for
scope and lifetime values as in `grant`.
Name the person by `subjectId` or `subjectEmail`; `reason` (up to 1024 characters) is shown to them. The request waits
up to seven days for their decision. `expiresInSeconds` is how long the delegation will last once approved (reported as
`requestedSeconds`); the person may change it and the scope when approving. When the person has an email address and
the deployment configures `sendEmail`, they receive the `delegation-request` email, which links to your approval page
through `links.delegation` in the email templates. Each request counts toward a per-agent rate limit, even when it is
refused.
```ts
const request = await iam.api.delegations.request(agentKey, {
tenantId,
subjectEmail: 'alice@acme.test',
scopes: ['calendar:read', 'calendar:write'],
reason: 'Schedule your interviews for next week',
expiresInSeconds: 7 * 86_400,
});
```
```ts title="Signature"
iam.api.delegations.request(
credential: CredentialInput,
input: DelegationScopeInput & {
tenantId: string;
subjectId?: string;
subjectEmail?: string;
reason: string;
expiresInSeconds?: number;
maxSessionSeconds?: number;
},
): Promise
```
## requestConfirmation [#requestconfirmation]
Asks the person an agent acts for to confirm one action the delegation holds back.
**HTTP:** `POST /api/iam/delegations/requestConfirmation` (requires a credential) · **Browser client:** `client.delegations.requestConfirmation()`
* **Permission:** None beyond the agent's delegated session (from `assume`) in the delegation's tenant.
* **Audited as:** `delegation:confirmation-request`, on the delegation, with the confirmation id, the action, and the
resource.
* **Errors:** `ACCESS_DENIED` for any credential other than a delegated session; `INVALID_INPUT` when the action does
not match the delegation's `confirm` patterns, or for a malformed action, resource, reason, or `validSeconds`
outside 30 to 3600; `RATE_LIMITED` (429) when the delegation asks too often.
A delegation created with `confirm` (action patterns, on `grant`, `request`, or `approve`) refuses those actions to
its delegated sessions until the person approves them one call at a time: the decision is refused with the internal
reason `CONFIRMATION_REQUIRED` (callers see an ordinary denial). The agent names the `action` and the `resource` it
wants to act on and a `reason` for the person; the request waits up to 30 minutes and is emailed to the person
(template `delegation-confirmation`) when the deployment sends email. Asking again for the same action and resource
while a request is pending returns that request. Once approved, exactly that action on that resource is allowed for
`validSeconds` (300 by default); the agent polls `getConfirmation` and retries.
```ts
const request = await iam.api.delegations.requestConfirmation(delegatedSession, {
tenantId,
action: 'documents:delete',
resource: { type: 'document', id: 'q3-draft' },
reason: 'You asked me to clean up the drafts folder',
validSeconds: 120,
});
```
```ts title="Signature"
iam.api.delegations.requestConfirmation(
credential: CredentialInput,
input: {
tenantId: string;
action: string;
resource: { type: string; id: string };
reason: string;
validSeconds?: number;
},
): Promise
```
## revoke [#revoke]
Ends a pending or active delegation and deletes its live delegated sessions at once.
**HTTP:** `POST /api/iam/delegations/revoke` (requires a credential) · **Browser client:** `client.delegations.revoke()`
* **Permission:** None for the person, the agent (with its own key), or the agent's sponsor; anyone else needs
`iam:delegations:revoke` on the delegation.
* **Audited as:** `delegation:revoke`, with the agent, the person, `sessionsEnded`, `handoffsRevoked` (when hand-offs
below it ended too), and the `reason`; an administrator's call also as `iam:delegations:revoke`.
* **Errors:** `INVALID_TRANSITION` (409) when it is already denied or revoked; `NOT_FOUND` when it is not in this
tenant.
`reason` (up to 512 characters) goes to the audit event. A revoked delegation cannot be used again; the agent needs a
new `grant` or an approved `request`. To stop an agent for everyone at once, suspend it with
[`agents.suspend`](/docs/reference/api/agents#suspend).
```ts title="Signature"
iam.api.delegations.revoke(
credential: CredentialInput,
input: { tenantId: string; delegationId: string; reason?: string },
): Promise
```
# departments (/docs/reference/api/departments)
> Departments are the organization's reporting structure: Engineering, Finance, Sales, and their sub-departments.
Departments are the organization's reporting structure: Engineering, Finance, Sales, and their sub-departments. Each
person belongs to at most one department, a department can name a head, a code, and a cost center, and teams can be
filed under a department. Teams, the working units, are the [`teams`](/docs/reference/api/teams) group.
## Departments in policies [#departments-in-policies]
Every evaluation for a person in their own tenant can read two [condition](/docs/guides/authorization/conditions)
keys, loaded only when a policy names them:
* `principal.departments`: the person's department ID and the IDs of every department above it.
* `principal.departmentId`: the person's own department; absent without one.
```json
{
"effect": "allow",
"actions": ["documents:write"],
"resources": ["*"],
"conditions": { "StringEquals": { "principal.departmentId": "${resource.departmentId}" } }
}
```
`{ "ArrayContains": { "principal.departments": [""] } }` admits everyone in Engineering and its
sub-departments. Sessions of an assumed role see an empty list.
## Managers from the org chart [#managers-from-the-org-chart]
`syncManagers` sets each person's manager (`managerId`) to the head of their department, and a head's to the nearest
head above. Approvals routed to managers (eligible bindings and access packages with `managerApproval`, certification
campaigns with `reviewerMode: 'manager'`) then follow the org chart.
## Birthright packages [#birthright-packages]
[Access package rules](/docs/guides/privileged-access/automatic-assignment) may test `identity.departments`: a
person's department ID and the IDs of the departments above it. `assign`, `unassign`, `importFromAttribute`, moving or
deleting a department, and `syncManagers` (rules may test `identity.managerId`) re-evaluate the rules for the people
they touch once they commit: joiners get their department's packages at once, and movers and leavers lose them.
| Method | What it does | Access |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`assign`](#assign) | Places up to 100 people (`identityIds`, or one `identityId`) in a department, moving them out of any other, with an optional `title`. Returns how many changed. | Credential |
| [`create`](#create) | Creates a department, optionally under `parentId`, with a `code`, a `headId` (an active person of the organization), a `costCenter`, and a `description`. | Credential |
| [`delete`](#delete) | Deletes a department. Its people become unassigned and its teams lose the link. | Credential |
| [`get`](#get) | One department with its path (the departments above it), sub-departments, head, and teams, and member counts with and without the departments below. | Credential |
| [`importFromAttribute`](#importfromattribute) | Places every active person whose string identity attribute (such as `department`, filled by SCIM provisioning or an onboarding form) names a department, matched by name or code ignoring case. `createMissing` creates top-level departments for values nothing matches; `dryRun` reports without changing anything. | Credential |
| [`list`](#list) | Every department with member counts (with and without sub-departments), child and team counts, in name order. | Credential |
| [`listMembers`](#listmembers) | The people of a department, heads first; `includeSubdepartments` adds those of every department below, each with their department. Each entry carries the person's title, since when they are in the department, and their manager. | Credential |
| [`mine`](#mine) | Your own place in the org chart and, if you head departments, the people you lead. | Credential |
| [`ofIdentity`](#ofidentity) | A person's department with the path from the top, their title, since when, the department's head and cost center; null when they have none. | Credential |
| [`suggestBirthright`](#suggestbirthright) | Roles and groups that most of a department's people already hold by hand, proposed as a ready-made automatic access package whose rule names the department. | Credential |
| [`syncManagers`](#syncmanagers) | Makes department heads the managers of their departments' people (see above). Without `overwrite` only people without a manager change; `departmentId` limits the run to one department and those below it; `dryRun` reports only. Returns the changes with names, how many kept another manager, and how many had no head above them. Never creates a cycle. | Credential |
| [`tree`](#tree) | The org chart: top-level departments with their sub-departments, heads, and member counts. | Credential |
| [`unassign`](#unassign) | Takes a person out of their department. | Credential |
| [`update`](#update) | Renames, re-codes, moves (`parentId`, null for top level), or changes the head, cost center, or description of a department; null (or an empty string) clears an optional field. | Credential |
## assign [#assign]
Places up to 100 people (`identityIds`, or one `identityId`) in a department, moving them out of any other, with an optional `title`. Returns how many changed.
**HTTP:** `POST /api/iam/departments/assign` (requires a credential) · **Browser client:** `client.departments.assign()`
* **Permission:** `iam:departments:manage` on `iam/{departmentId}`.
* **Audited as:** `iam:departments:manage` and `department:assign` per person (`previousDepartmentId` when moved).
* **Errors:** `INVALID_INPUT` for a service account or agent, or without people; `NOT_FOUND`.
```ts title="Signature"
iam.api.departments.assign(
credential: CredentialInput,
input: {
tenantId: string;
departmentId: string;
identityIds?: string[];
identityId?: string;
title?: string;
},
): Promise<{ assigned: number; unchanged: number }>
```
## create [#create]
Creates a department, optionally under `parentId`, with a `code`, a `headId` (an active person of the organization), a `costCenter`, and a `description`.
**HTTP:** `POST /api/iam/departments/create` (requires a credential) · **Browser client:** `client.departments.create()`
* **Permission:** `iam:departments:manage` on the tenant.
* **Audited as:** `iam:departments:manage` and `department:create`.
* **Errors:** `CONFLICT` (409) when the name or code (ignoring case) is taken; `INVALID_INPUT` for a bad code, a head
who is not a person, or more than twenty levels of nesting; `LIMIT_EXCEEDED` past 2000 departments.
```ts
const engineering = await iam.api.departments.create(credential, {
tenantId,
name: 'Engineering',
code: 'ENG',
headId,
costCenter: 'CC-100',
});
```
```ts title="Signature"
iam.api.departments.create(
credential: CredentialInput,
input: DepartmentInput,
): Promise
```
## delete [#delete]
Deletes a department. Its people become unassigned and its teams lose the link.
**HTTP:** `POST /api/iam/departments/delete` (requires a credential) · **Browser client:** `client.departments.delete()`
* **Permission:** `iam:departments:manage` on the department.
* **Audited as:** `iam:departments:manage` and `department:delete` (`unassigned`, `teams`).
* **Errors:** `RESOURCE_IN_USE` (409) while departments sit below it, or while an access package rule names it
(`identity.departments`).
```ts title="Signature"
iam.api.departments.delete(
credential: CredentialInput,
input: { tenantId: string; departmentId: string },
): Promise<{ unassigned: number; teams: number; deleted: true }>
```
## get [#get]
One department with its path (the departments above it), sub-departments, head, and teams, and member counts with and without the departments below.
**HTTP:** `POST /api/iam/departments/get` (requires a credential) · **Browser client:** `client.departments.get()`
* **Permission:** `iam:departments:read` on the department.
```ts title="Signature"
iam.api.departments.get(
credential: CredentialInput,
input: { tenantId: string; departmentId: string },
): Promise
```
## importFromAttribute [#importfromattribute]
Places every active person whose string identity attribute (such as `department`, filled by SCIM provisioning or an onboarding form) names a department, matched by name or code ignoring case. `createMissing` creates top-level departments for values nothing matches; `dryRun` reports without changing anything.
**HTTP:** `POST /api/iam/departments/importFromAttribute` (requires a credential) · **Browser client:** `client.departments.importFromAttribute()`
* **Permission:** `iam:departments:manage` on the tenant.
* **Audited as:** `iam:departments:manage`, and `department:create` / `department:assign` for what changed.
* **Errors:** `INVALID_INPUT` when the attribute is not a declared string identity attribute.
```ts
const result = await iam.api.departments.importFromAttribute(credential, {
tenantId,
attribute: 'department',
createMissing: true,
dryRun: true,
});
// { dryRun: true, created: ['Sales'], assigned: 12, unchanged: 30, unmatched: [], missing: 2 }
```
```ts title="Signature"
iam.api.departments.importFromAttribute(
credential: CredentialInput,
input: {
tenantId: string;
attribute: string;
createMissing?: boolean;
dryRun?: boolean;
},
): Promise
```
## list [#list]
Every department with member counts (with and without sub-departments), child and team counts, in name order.
**HTTP:** `POST /api/iam/departments/list` (requires a credential) · **Browser client:** `client.departments.list()`
* **Permission:** `iam:departments:read` on the tenant.
```ts title="Signature"
iam.api.departments.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listMembers [#listmembers]
The people of a department, heads first; `includeSubdepartments` adds those of every department below, each with their department. Each entry carries the person's title, since when they are in the department, and their manager.
**HTTP:** `POST /api/iam/departments/listMembers` (requires a credential) · **Browser client:** `client.departments.listMembers()`
* **Permission:** `iam:departments:read` on the department.
```ts title="Signature"
iam.api.departments.listMembers(
credential: CredentialInput,
input: { tenantId: string; departmentId: string; includeSubdepartments?: boolean },
): Promise
```
## mine [#mine]
Your own place in the org chart and, if you head departments, the people you lead.
**HTTP:** `POST /api/iam/departments/mine` (requires a credential) · **Browser client:** `client.departments.mine()`
* **Permission:** None beyond an ordinary session (or API key) of a person in the organization.
* **Audited as:** Not audited; it only reads.
* **Errors:** `ACCESS_DENIED` for a service account, an agent, a temporary credential, or another tenant's session.
`department` is your department with the path from the top, your title, since when, its head, and its cost center
(null without a department). `leads` lists each department you head with its people and those of every department
below it (name, email, department, title, and manager), heads first. Use it for a "my team" page that managers can
open without `iam:departments:read`.
```ts title="Signature"
iam.api.departments.mine(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## ofIdentity [#ofidentity]
A person's department with the path from the top, their title, since when, the department's head and cost center; null when they have none.
**HTTP:** `POST /api/iam/departments/ofIdentity` (requires a credential) · **Browser client:** `client.departments.ofIdentity()`
* **Permission:** `iam:departments:read` on `iam/{identityId}`.
```ts title="Signature"
iam.api.departments.ofIdentity(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise<{
costCenter?: string | undefined;
head?: DepartmentPerson | undefined;
since: number;
title?: string | undefined;
department: DepartmentRef;
path: DepartmentRef[];
} | null>
```
## suggestBirthright [#suggestbirthright]
Roles and groups that most of a department's people already hold by hand, proposed as a ready-made automatic access package whose rule names the department.
**HTTP:** `POST /api/iam/departments/suggestBirthright` (requires a credential) · **Browser client:** `client.departments.suggestBirthright()`
* **Permission:** `iam:analysis:read` on the tenant.
* **Audited as:** Not audited; it only reads.
* **Errors:** `INVALID_INPUT` for `minShare` outside 0.5-1; `NOT_FOUND` for an unknown `departmentId`.
A department's people are exactly who a rule naming it would match: active people placed in it or in a department
below it. Only plain grants count (standing, permanent role bindings made to the person and permanent memberships of
ordinary groups, none from an access package), an item must be held by at least `minShare` (default 0.8) of at least
`minPeople` (default 3) people, and nothing is suggested twice: not what is suggested for a department above, not
what an automatic package naming the department (or one above) grants, and not what most of the department already
receives from any automatic package. Each suggestion carries the shares, `wouldGrant` (people who would gain
something), `existingPackages`, and `package`, ready for [`packages.create`](/docs/reference/api/packages#create):
```ts
const [suggestion] = await iam.api.departments.suggestBirthright(credential, {
tenantId,
departmentId: engineeringId,
});
if (suggestion) await iam.api.packages.create(credential, { tenantId, ...suggestion.package });
```
```ts title="Signature"
iam.api.departments.suggestBirthright(
credential: CredentialInput,
input: {
tenantId: string;
departmentId?: string;
minShare?: number;
minPeople?: number;
},
): Promise
```
## syncManagers [#syncmanagers]
Makes department heads the managers of their departments' people (see above). Without `overwrite` only people without a manager change; `departmentId` limits the run to one department and those below it; `dryRun` reports only. Returns the changes with names, how many kept another manager, and how many had no head above them. Never creates a cycle.
**HTTP:** `POST /api/iam/departments/syncManagers` (requires a credential) · **Browser client:** `client.departments.syncManagers()`
* **Permission:** `iam:identities:update` on the tenant (or the department) and `iam:departments:read`.
* **Audited as:** `iam:identities:update` and `department:sync-managers`.
```ts title="Signature"
iam.api.departments.syncManagers(
credential: CredentialInput,
input: {
tenantId: string;
departmentId?: string;
overwrite?: boolean;
dryRun?: boolean;
},
): Promise
```
## tree [#tree]
The org chart: top-level departments with their sub-departments, heads, and member counts.
**HTTP:** `POST /api/iam/departments/tree` (requires a credential) · **Browser client:** `client.departments.tree()`
* **Permission:** `iam:departments:read` on the tenant.
```ts title="Signature"
iam.api.departments.tree(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## unassign [#unassign]
Takes a person out of their department.
**HTTP:** `POST /api/iam/departments/unassign` (requires a credential) · **Browser client:** `client.departments.unassign()`
* **Permission:** `iam:departments:manage` on `iam/{identityId}`.
* **Audited as:** `iam:departments:manage` and `department:unassign`.
* **Errors:** `NOT_FOUND` when the person has no department.
```ts title="Signature"
iam.api.departments.unassign(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise<{ deleted: true }>
```
## update [#update]
Renames, re-codes, moves (`parentId`, null for top level), or changes the head, cost center, or description of a department; null (or an empty string) clears an optional field.
**HTTP:** `POST /api/iam/departments/update` (requires a credential) · **Browser client:** `client.departments.update()`
* **Permission:** `iam:departments:manage` on the department.
* **Audited as:** `iam:departments:manage` and `department:update` (`fields`).
* **Errors:** `INVALID_INPUT` when moving under itself or a department below it, or past twenty levels; `CONFLICT` for
a taken name or code.
```ts title="Signature"
iam.api.departments.update(
credential: CredentialInput,
input: DepartmentUpdate,
): Promise
```
# domains (/docs/reference/api/domains)
> Verified email domains let an organization prove it owns a domain such as acme.com, so your sign-in page can find the right tenant from a work email address.
Verified email domains let an organization prove it owns a domain such as `acme.com`, so your sign-in page can find
the right tenant from a work email address. People do not know tenant IDs, and every sign-in names a tenant, so
"use your work email" needs a trustworthy mapping from domain to organization. This group creates that mapping and
answers the public lookup. It is step one of [enterprise onboarding](/docs/federation/enterprise-onboarding).
## How domain verification works [#how-domain-verification-works]
1. An administrator claims a domain with [`add`](#add). The claim starts as `pending` and comes with a DNS TXT
record to publish: the name `_better-iam-challenge.{domain}` with the value `better-iam-verification={token}`.
2. After publishing the record, the administrator calls [`verify`](#verify). The server looks the record up and,
when the value matches, marks the domain `verified`.
3. From then on, the public [`discover`](#discover) call maps email addresses at that domain to the organization.
A verified domain belongs to exactly one tenant. Several tenants may hold pending claims to the same domain, but
only the first to verify owns it; the others then fail with `DOMAIN_TAKEN`. Consumer mailbox providers (Gmail,
Outlook, iCloud, and similar) can never be claimed, because their addresses belong to individuals, not one
organization. Deployment options under `domains` change the TXT label (`recordName`), replace the blocked list
(`blockedDomains`), or inject the DNS resolver (`resolveTxt`), for example to use DNS over HTTPS.
Domains are normalized before use: lowercase, no trailing dot, and at least two labels with an alphabetic
top-level domain. Claims are removed when the tenant is purged.
| Method | What it does | Access |
| ----------------------- | --------------------------------------------------------------------------------------------------------- | ---------- |
| [`add`](#add) | Claims a domain for the tenant and returns the TXT record the organization must publish to prove control. | Credential |
| [`delete`](#delete) | Releases a domain claim; a verified domain stops resolving to the tenant immediately. | Credential |
| [`discover`](#discover) | Finds the organization that verified an email address's domain, with the sign-in rules it enforces. | Public |
| [`list`](#list) | Lists the tenant's claimed domains, newest first, with their status and the TXT record each one needs. | Credential |
| [`verify`](#verify) | Looks up the domain's TXT record and marks the claim verified when the record matches. | Credential |
## add [#add]
Claims a domain for the tenant and returns the TXT record the organization must publish to prove control.
**HTTP:** `POST /api/iam/domains/add` (requires a credential) · **Browser client:** `client.domains.add()`
* **Permission:** `iam:domains:create` on `iam/domains/{domain}`.
* **Audited as:** `iam:domains:create`.
* **Errors:** `INVALID_INPUT` when the value is not a domain such as `example.com`; `DOMAIN_NOT_ALLOWED` for a
shared mailbox provider on the blocked list; `CONFLICT` when this tenant already claimed the domain;
`DOMAIN_TAKEN` when another tenant has verified it.
The result carries `dnsRecord` (`type`, `name`, `value`); show it to the administrator exactly as returned. The
claim grants nothing until it is verified.
```ts
const claimed = await iam.api.domains.add(credential, { tenantId, domain: 'acme.com' });
// Publish claimed.dnsRecord at the DNS provider, then call domains.verify.
```
```ts title="Signature"
iam.api.domains.add(
credential: CredentialInput,
input: { tenantId: string; domain: string },
): Promise<{
id: string;
tenantId: string;
domain: string;
status: 'pending' | 'verified';
createdAt: number;
createdBy: string;
verifiedAt: number | undefined;
lastCheckedAt: number | undefined;
dnsRecord: { type: 'TXT'; name: string; value: string };
}>
```
## delete [#delete]
Releases a domain claim; a verified domain stops resolving to the tenant immediately.
**HTTP:** `POST /api/iam/domains/delete` (requires a credential) · **Browser client:** `client.domains.delete()`
* **Permission:** `iam:domains:delete` on `iam/domains/{domain}`.
* **Audited as:** `iam:domains:delete`.
* **Errors:** `NOT_FOUND` when the claim is not in this tenant.
Releasing a verified domain frees it, so another organization can then claim and verify it.
```ts title="Signature"
iam.api.domains.delete(
credential: CredentialInput,
input: { tenantId: string; domainId: string },
): Promise<{ deleted: boolean }>
```
## discover [#discover]
Finds the organization that verified an email address's domain, with the sign-in rules it enforces.
**HTTP:** `POST /api/iam/domains/discover` (no credential) · **Browser client:** `client.domains.discover()`
* **Permission:** None: public. No credential is needed.
* **Errors:** `INVALID_INPUT` when `email` has no `@` or the domain is malformed; `NOT_FOUND` when no active
organization verified the domain; `WRONG_REGION` (421) when another region serves the organization.
This is home-realm discovery for a login screen: the person types their email, you call `discover`, and you send
them to the right tenant with the right method. The answer carries the tenant's `tenantId`, `name`, `type`, alias
(`slug`, when set), `allowedMethods` (`null` means every method the deployment enables), and `requireMfa`, plus its
home `region` and `signInUrl` when organization addresses or regions are configured. Pass either `email` or
`domain`. In a multi-region deployment, an organization homed elsewhere answers `WRONG_REGION` with its sign-in URL
there, so a global sign-in page can send the person on.
Unknown, pending, released, and inactive domains, and tenants under an inactive ancestor, all answer the same
`NOT_FOUND`, so the call does not reveal which of these applies. The result is public discovery data by design;
apply ingress rate limits as you would for [`tenants.lookup`](/docs/reference/api/tenants#lookup).
```ts
const org = await client.domains.discover({ email: 'alice@acme.com' });
await client.auth.signIn({ tenantId: org.tenantId, email: 'alice@acme.com', password });
```
```ts title="Signature"
iam.api.domains.discover(
input: { email?: string; domain?: string },
): Promise
```
## list [#list]
Lists the tenant's claimed domains, newest first, with their status and the TXT record each one needs.
**HTTP:** `POST /api/iam/domains/list` (requires a credential) · **Browser client:** `client.domains.list()`
* **Permission:** `iam:domains:read` on `iam/domains/*`.
* **Audited as:** `iam:domains:read`.
Each entry shows `status` (`pending` or `verified`), when it was verified, and when the DNS record was last
checked (`lastCheckedAt`), which helps an administrator see whether a failed verification was retried.
```ts title="Signature"
iam.api.domains.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise<{
id: string;
tenantId: string;
domain: string;
status: 'pending' | 'verified';
createdAt: number;
createdBy: string;
verifiedAt: number | undefined;
lastCheckedAt: number | undefined;
dnsRecord: { type: 'TXT'; name: string; value: string };
}[]>
```
## verify [#verify]
Looks up the domain's TXT record and marks the claim verified when the record matches.
**HTTP:** `POST /api/iam/domains/verify` (requires a credential) · **Browser client:** `client.domains.verify()`
* **Permission:** `iam:domains:update` on `iam/domains/{domain}`.
* **Audited as:** `iam:domains:update`.
* **Errors:** `NOT_FOUND` when the claim is not in this tenant; `DOMAIN_TAKEN` when another tenant verified the
domain first.
A missing or not-yet-visible record is not an error: the call returns `verified: false`, records `lastCheckedAt`,
and leaves the claim pending, so you can retry after DNS propagates. The DNS lookup runs before the database
transaction opens, and a failed lookup counts as "not found". Verifying a domain that is already verified returns
`verified: true` without another lookup.
```ts title="Signature"
iam.api.domains.verify(
credential: CredentialInput,
input: { tenantId: string; domainId: string },
): Promise<{
verified: boolean;
domain: {
id: string;
tenantId: string;
domain: string;
status: 'pending' | 'verified';
createdAt: number;
createdBy: string;
verifiedAt: number | undefined;
lastCheckedAt: number | undefined;
dnsRecord: { type: 'TXT'; name: string; value: string };
};
}>
```
# features (/docs/reference/api/features)
> Feature flags turn product features on and off per tenant without a deploy.
Feature flags turn product features on and off per tenant without a deploy. Flags the root tenant defines are
platform flags and reach every organization and project; flags an organization or project defines reach only its own
subtree. A key belongs to the tenant closest to the root, so a tenant can never shadow a platform flag. Every flag is
a boolean, read by applications through `evaluate`, by server code through `iam.features` (`evaluate`, `values`,
`isEnabled`, no credential), and by policies through the `tenant.features` condition key. The repository guide is
`docs/feature-flags.md`.
## How a value is decided [#how-a-value-is-decided]
For a given tenant, the first rule that applies decides:
1. The **kill switch** (`killSwitch: true`): off everywhere.
2. The **closest target or override** on the way from the tenant up to (not including) the defining tenant. A target
is a value the flag's managers pin for a tenant below them (`setTarget`); an override is a tenant's own choice for
a `tenantOverridable` flag (`setOverride`). On the same tenant the override wins; a `locked` target silences
overrides at its tenant and below. Targets can lapse (`expiresAt`).
3. The **rollout** (`rolloutPercentage`, 0 to 100): a stable share of the branches directly below the defining tenant
gets `true`, so an organization and its projects land on the same side. Raising it only adds tenants.
4. The **default** (`defaultValue`).
Resources are `iam/features/{key}` (and `iam/features` for `list`), so a policy can delegate one flag or a family,
such as `iam:features:override` on `iam/features/beta-*`.
| Method | What it does | Access |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`create`](#create) | Defines a flag in the tenant: a platform flag on the root tenant, a flag for the tenant's subtree elsewhere. | Credential |
| [`delete`](#delete) | Deletes a flag together with every target and override set for it. | Credential |
| [`evaluate`](#evaluate) | Returns `{ tenantId, flags: { key: boolean } }` for the flags that reach the tenant, for applications deciding what to show. | Credential |
| [`list`](#list) | The flags that reach the tenant, sorted by key, each with its value for the tenant and why. | Credential |
| [`listTargets`](#listtargets) | Every target and override below the defining tenant for one flag, newest first. | Credential |
| [`setOverride`](#setoverride) | Records a tenant's own choice for a flag an ancestor defines with `tenantOverridable: true`. | Credential |
| [`setTarget`](#settarget) | Pins a flag's value for a tenant below the defining tenant (`tenantId`), and for that tenant's descendants. | Credential |
| [`update`](#update) | Changes a flag's settings. Fields left out keep their value. | Credential |
## create [#create]
Defines a flag in the tenant: a platform flag on the root tenant, a flag for the tenant's subtree elsewhere.
**HTTP:** `POST /api/iam/features/create` (requires a credential) · **Browser client:** `client.features.create()`
* **Permission:** `iam:features:manage` on `iam/features/{key}`.
* **Audited as:** `feature:create`, with the settings.
* **Errors:** `CONFLICT` (409) when the tenant or one of its ancestors already defines the key; `LIMIT_EXCEEDED` (409)
past 200 flags in the tenant; `INVALID_INPUT` for a malformed key, an unknown field, `rolloutPercentage` with
`defaultValue: true`, or an `internal` flag that is also `tenantOverridable`; `INVALID_TRANSITION` in a deleted
tenant.
Keys are lowercase letters and digits joined by `-`, `_`, or `.`, starting with a letter, at most 64 characters.
Settings default to off, not overridable, no kill switch, not internal. `internal` flags are evaluated only by server
code and policies and are hidden from tenants below the defining one.
```ts
await iam.api.features.create(rootCredential, {
tenantId: rootTenantId,
key: 'new-billing',
description: 'The redesigned billing pages',
tenantOverridable: true,
});
```
```ts title="Signature"
iam.api.features.create(
credential: CredentialInput,
input: {
tenantId: string;
key: string;
description?: string;
defaultValue?: boolean;
rolloutPercentage?: number;
tenantOverridable?: boolean;
killSwitch?: boolean;
internal?: boolean;
},
): Promise
```
## delete [#delete]
Deletes a flag together with every target and override set for it.
**HTTP:** `POST /api/iam/features/delete` (requires a credential) · **Browser client:** `client.features.delete()`
* **Permission:** `iam:features:manage` on `iam/features/{key}`.
* **Audited as:** `feature:delete`, with `removedTargets`.
* **Errors:** `NOT_FOUND` when the tenant does not define the key.
Code that still asks for the key gets `false` (reason `UNKNOWN`). The result is `{ success: true, removedTargets }`.
```ts title="Signature"
iam.api.features.delete(
credential: CredentialInput,
input: { tenantId: string; key: string },
): Promise<{ success: true; removedTargets: number }>
```
## evaluate [#evaluate]
Returns `{ tenantId, flags: { key: boolean } }` for the flags that reach the tenant, for applications deciding what to show.
**HTTP:** `POST /api/iam/features/evaluate` (requires a credential) · **Browser client:** `client.features.evaluate()`
* **Permission:** None beyond a session (user, API key, role, or session token) of the tenant; root administrators may
evaluate any tenant.
* **Audited as:** not audited.
* **Errors:** `ACCESS_DENIED` (403) for a session of another tenant; `INVALID_INPUT` for a malformed key or more than
100 keys.
Internal flags of ancestors are left out. `keys` limits the answer to those flags, and a requested key that no flag
defines comes back `false`. The React hooks `useFeatureFlags` and `useFeatureFlag` call it.
```ts
const { flags } = await client.features.evaluate({ tenantId });
if (flags['new-billing']) showNewBilling();
```
```ts title="Signature"
iam.api.features.evaluate(
credential: CredentialInput,
input: { tenantId: string; keys?: string[] },
): Promise<{ tenantId: string; flags: Record }>
```
## list [#list]
The flags that reach the tenant, sorted by key, each with its value for the tenant and why.
**HTTP:** `POST /api/iam/features/list` (requires a credential) · **Browser client:** `client.features.list()`
* **Permission:** `iam:features:read` on `iam/features`.
* **Audited as:** `iam:features:read`.
Each entry has `evaluation` (`value`, `reason` of `KILL_SWITCH`, `TARGET`, `OVERRIDE`, `ROLLOUT`, or `DEFAULT`,
`decidedBy`, `expiresAt`, `locked`, and `overridable`), the tenant's own `override`, and the `target` pinned for it.
Flags the tenant defines also carry `definition` with their settings. Ancestors' internal flags are left out, except
for root administrators, who see every flag with its definition. `shadowed` lists flags this tenant defines whose key
an ancestor also defines, so the ancestor's flag applies. Rename or delete them.
```ts title="Signature"
iam.api.features.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise<{
tenantId: string;
flags: FeatureFlagView[];
shadowed: FeatureFlagDefinition[];
}>
```
## listTargets [#listtargets]
Every target and override below the defining tenant for one flag, newest first.
**HTTP:** `POST /api/iam/features/listTargets` (requires a credential) · **Browser client:** `client.features.listTargets()`
* **Permission:** `iam:features:read` on `iam/features/{key}`, in the defining tenant.
* **Audited as:** `iam:features:read`.
* **Errors:** `NOT_FOUND` when the tenant does not define the key.
Each entry names the tenant (`tenantName`, `tenantStatus`), the `source` (`target` or `override`), the value,
`locked`, `expiresAt`, the `note`, and `active`: false once a target has lapsed or an override is no longer allowed.
Only the defining tenant's managers see notes.
```ts title="Signature"
iam.api.features.listTargets(
credential: CredentialInput,
input: { tenantId: string; key: string },
): Promise
```
## setOverride [#setoverride]
Records a tenant's own choice for a flag an ancestor defines with `tenantOverridable: true`.
**HTTP:** `POST /api/iam/features/setOverride` (requires a credential) · **Browser client:** `client.features.setOverride()`
* **Permission:** `iam:features:override` on `iam/features/{key}`, in the tenant.
* **Audited as:** `feature:override`, with the value (`null` when withdrawn).
* **Errors:** `FEATURE_LOCKED` (409) when the flag does not allow overrides or a locked target covers the tenant;
`NOT_FOUND` for a key no flag defines or an ancestor's internal flag; `INVALID_INPUT` when the tenant defines the flag
itself (change it with `update`).
The choice applies to the tenant and its descendants unless something closer decides. `value: null` withdraws it,
which is always allowed. The result is the tenant's new evaluation.
```ts title="Signature"
iam.api.features.setOverride(
credential: CredentialInput,
input: { tenantId: string; key: string; value: boolean | null },
): Promise
```
## setTarget [#settarget]
Pins a flag's value for a tenant below the defining tenant (`tenantId`), and for that tenant's descendants.
**HTTP:** `POST /api/iam/features/setTarget` (requires a credential) · **Browser client:** `client.features.setTarget()`
* **Permission:** `iam:features:manage` on `iam/features/{key}`, in the defining tenant.
* **Audited as:** `feature:target`, recorded in the defining tenant with `targetTenantId`, `value`, `locked`, and
`expiresAt`.
* **Errors:** `INVALID_INPUT` when `targetTenantId` is not below the defining tenant (use `defaultValue` for the tenant
itself) or `expiresAt` is not in the next ten years; `NOT_FOUND` when the tenant does not define the key.
`locked: true` keeps the tenant and everything below it from overriding. `expiresAt` makes the target lapse by
itself, for a trial or a temporary block. `note` is visible only to the defining tenant's managers. `value: null`
removes the target.
```ts
await iam.api.features.setTarget(rootCredential, {
tenantId: rootTenantId,
key: 'fast-search',
targetTenantId: acmeId,
value: true,
expiresAt: Date.now() + 14 * 86_400_000,
note: 'Design partner trial',
});
```
```ts title="Signature"
iam.api.features.setTarget(
credential: CredentialInput,
input: {
tenantId: string;
key: string;
targetTenantId: string;
value: boolean | null;
locked?: boolean;
expiresAt?: number;
note?: string;
},
): Promise
```
## update [#update]
Changes a flag's settings. Fields left out keep their value.
**HTTP:** `POST /api/iam/features/update` (requires a credential) · **Browser client:** `client.features.update()`
* **Permission:** `iam:features:manage` on `iam/features/{key}`.
* **Audited as:** `feature:update`, with the settings `before` and `after`.
* **Errors:** `NOT_FOUND` when the tenant does not define the key; `INVALID_INPUT` as for `create`.
`null` clears `description` or `rolloutPercentage`. Turning `killSwitch` on switches the flag off for every tenant at
once, and turning it off restores the stored targets, overrides, and rollout. Turning `tenantOverridable` off keeps
existing overrides but ignores them.
```ts title="Signature"
iam.api.features.update(
credential: CredentialInput,
input: {
tenantId: string;
key: string;
description?: string | null;
defaultValue?: boolean;
rolloutPercentage?: number | null;
tenantOverridable?: boolean;
killSwitch?: boolean;
internal?: boolean;
},
): Promise
```
# groups (/docs/reference/api/groups)
> Groups let you grant access to many people at once.
Groups let you grant access to many people at once. Instead of binding the same roles to every member of the
finance team, you bind them once to a "Finance" group and manage who is in it; everyone in the group receives every
role bound to it, and leaving the group removes that access immediately. Memberships can be temporary, which suits
contractors, on-call rotations, and project teams.
## How group access works [#how-group-access-works]
A group has no permissions of its own. Access comes from [role bindings](/docs/guides/authorization/roles) whose
subject is the group, so changing membership is, in effect, granting or revoking those roles. That is why adding or
removing a member requires authority over each of the group's bindings, not just permission to edit the group:
someone who could not grant a role directly cannot grant it by putting a person in a group.
Temporary memberships carry an `expiresAt` (epoch milliseconds). They stop counting the moment they lapse, are left
out of `listMembers`, and are removed later by the purge job. Memberships created by an
[access package](/docs/guides/privileged-access/access-packages) are tagged with the package's assignment; editing
one by hand takes it over, so revoking the package no longer removes it.
| Method | What it does | Access |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------- |
| [`addMember`](#addmember) | Adds a person to a group so they receive every role bound to the group, optionally until a given time. | Credential |
| [`addMembers`](#addmembers) | Adds up to 100 people to a group in one transaction, all with the same optional expiry. | Credential |
| [`create`](#create) | Creates a group in the tenant. | Credential |
| [`delete`](#delete) | Deletes a group together with its memberships, its role bindings, their activations, and its relationship tuples. | Credential |
| [`get`](#get) | Returns one group by id. | Credential |
| [`list`](#list) | Lists every group in the tenant. | Credential |
| [`listMembers`](#listmembers) | Lists the current members of a group, with `membershipExpiresAt` on temporary memberships. | Credential |
| [`removeMember`](#removemember) | Removes a person from a group, ending the access the group's roles gave them. | Credential |
| [`update`](#update) | Renames a group or changes its description. | Credential |
| [`updateMember`](#updatemember) | Extends, shortens, or clears the expiry of an existing membership. | Credential |
## addMember [#addmember]
Adds a person to a group so they receive every role bound to the group, optionally until a given time.
**HTTP:** `POST /api/iam/groups/addMember` (requires a credential) · **Browser client:** `client.groups.addMember()`
* **Permission:** `iam:groups:update` on the group, plus grant authority for each of the group's role bindings.
* **Audited as:** `iam:groups:update`.
* **Errors:** `CONFLICT` when the person is already a live member; `NOT_FOUND` when the group or person is not in
this tenant; `ACCESS_DENIED` without authority over one of the group's bindings; `SOD_CONFLICT` when the
membership would give the person a combination of roles a
[separation-of-duties rule](/docs/guides/authorization/separation-of-duties) forbids.
Pass `expiresAt` to make the membership temporary. Adding someone whose earlier membership has lapsed renews it
instead of failing, and the renewed membership no longer belongs to the access package that originally created it.
```ts
// Give a contractor the team's access for 30 days.
await iam.api.groups.addMember(credential, {
tenantId,
groupId,
identityId,
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
});
```
```ts title="Signature"
iam.api.groups.addMember(
credential: CredentialInput,
input: { tenantId: string; groupId: string; identityId: string; expiresAt?: number },
): Promise
```
## addMembers [#addmembers]
Adds up to 100 people to a group in one transaction, all with the same optional expiry.
**HTTP:** `POST /api/iam/groups/addMembers` (requires a credential) · **Browser client:** `client.groups.addMembers()`
* **Permission:** `iam:groups:update` on the group, plus grant authority for each of the group's role bindings.
* **Audited as:** `iam:groups:update`.
* **Errors:** `INVALID_INPUT` when `identityIds` is empty; any error `addMember` can raise for one person
(including `SOD_CONFLICT`) rejects the whole batch.
Use it for cohort onboarding, such as a new class of employees or everyone joining a project on the same day.
Duplicate ids are ignored. Because it is atomic, either everyone is added or no one is.
```ts title="Signature"
iam.api.groups.addMembers(
credential: CredentialInput,
input: {
tenantId: string;
groupId: string;
identityIds: string[];
expiresAt?: number;
},
): Promise<{ members: GroupMember[] }>
```
## create [#create]
Creates a group in the tenant.
**HTTP:** `POST /api/iam/groups/create` (requires a credential) · **Browser client:** `client.groups.create()`
* **Permission:** `iam:groups:create` on the tenant.
* **Audited as:** `iam:groups:create`.
* **Errors:** `LIMIT_EXCEEDED` when the tenant's plan limit for groups is reached; `INVALID_INPUT` for an empty name
or a description over 512 characters.
A new group is empty and grants nothing until you bind roles to it with
[`bindings.create`](/docs/reference/api/bindings#create) and add members.
```ts title="Signature"
iam.api.groups.create(
credential: CredentialInput,
input: GroupInput,
): Promise
```
## delete [#delete]
Deletes a group together with its memberships, its role bindings, their activations, and its relationship tuples.
**HTTP:** `POST /api/iam/groups/delete` (requires a credential) · **Browser client:** `client.groups.delete()`
* **Permission:** `iam:groups:delete` on the group, plus grant authority for each of its role bindings.
* **Audited as:** `iam:groups:delete`.
* **Errors:** `RESOURCE_IN_USE` (409) when an access package still grants the group or names it in an automatic
assignment rule, or when the group approves package requests or eligible-binding activations.
The in-use checks exist so deleting a group never silently changes who can approve requests or what a package
grants: point those at another group first.
```ts title="Signature"
iam.api.groups.delete(
credential: CredentialInput,
input: { tenantId: string; groupId: string },
): Promise<{ deleted: boolean }>
```
## get [#get]
Returns one group by id.
**HTTP:** `POST /api/iam/groups/get` (requires a credential) · **Browser client:** `client.groups.get()`
* **Permission:** `iam:groups:read` on the group.
* **Audited as:** `iam:groups:read`.
* **Errors:** `NOT_FOUND` when the group is not in this tenant.
```ts title="Signature"
iam.api.groups.get(
credential: CredentialInput,
input: { tenantId: string; groupId: string },
): Promise
```
## list [#list]
Lists every group in the tenant.
**HTTP:** `POST /api/iam/groups/list` (requires a credential) · **Browser client:** `client.groups.list()`
* **Permission:** `iam:groups:read` on the tenant.
* **Audited as:** `iam:groups:read`.
```ts title="Signature"
iam.api.groups.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listMembers [#listmembers]
Lists the current members of a group, with `membershipExpiresAt` on temporary memberships.
**HTTP:** `POST /api/iam/groups/listMembers` (requires a credential) · **Browser client:** `client.groups.listMembers()`
* **Permission:** `iam:groups:read` on the group.
* **Audited as:** `iam:groups:read`.
* **Errors:** `NOT_FOUND` when the group is not in this tenant.
Lapsed memberships are left out even before the purge job removes them, so the list always matches who currently
receives the group's roles. Members are returned as public identities, without credential material.
```ts title="Signature"
iam.api.groups.listMembers(
credential: CredentialInput,
input: { tenantId: string; groupId: string },
): Promise<(PublicIdentity & { membershipExpiresAt?: number })[]>
```
## removeMember [#removemember]
Removes a person from a group, ending the access the group's roles gave them.
**HTTP:** `POST /api/iam/groups/removeMember` (requires a credential) · **Browser client:** `client.groups.removeMember()`
* **Permission:** `iam:groups:update` on the group, plus grant authority for each of the group's role bindings.
* **Audited as:** `iam:groups:update`.
Any [just-in-time activations](/docs/guides/privileged-access/elevation) the person had of the group's eligible
bindings end at the same time, so removing someone from a group cannot leave them elevated. Removing a person who is
not a member succeeds and changes nothing.
```ts title="Signature"
iam.api.groups.removeMember(
credential: CredentialInput,
input: { tenantId: string; groupId: string; identityId: string },
): Promise<{ deleted: boolean }>
```
## update [#update]
Renames a group or changes its description.
**HTTP:** `POST /api/iam/groups/update` (requires a credential) · **Browser client:** `client.groups.update()`
* **Permission:** `iam:groups:update` on the group.
* **Audited as:** `iam:groups:update`.
* **Errors:** `INVALID_INPUT` when neither `name` nor `description` is given.
```ts title="Signature"
iam.api.groups.update(
credential: CredentialInput,
input: { tenantId: string; groupId: string; name?: string; description?: string },
): Promise
```
## updateMember [#updatemember]
Extends, shortens, or clears the expiry of an existing membership.
**HTTP:** `POST /api/iam/groups/updateMember` (requires a credential) · **Browser client:** `client.groups.updateMember()`
* **Permission:** `iam:groups:update` on the group, plus grant authority for each of the group's role bindings.
* **Audited as:** `iam:groups:update`.
* **Errors:** `NOT_FOUND` when the person is not a live member of the group.
Pass `expiresAt: null` to make a temporary membership permanent. Editing a membership that an access package
created takes it over: revoking the package will no longer remove it.
```ts title="Signature"
iam.api.groups.updateMember(
credential: CredentialInput,
input: {
tenantId: string;
groupId: string;
identityId: string;
expiresAt: number | null;
},
): Promise
```
# hostnames (/docs/reference/api/hostnames)
> Custom hostnames let an organization sign in at an address of its own, such as login.acme.com, instead of the subdomain the deployment gives it (acme.signin.ex…
Custom hostnames let an organization sign in at an address of its own, such as `login.acme.com`, instead of the
subdomain the deployment gives it (`acme.signin.example.com`). Enterprise customers ask for this so the sign-in page
sits on their own domain, which their people recognize and their security team can vouch for. This group claims a
hostname, hands back the DNS records that prove control and route traffic, verifies it, makes it the organization's
primary address, and releases it. It needs `hosts.customHostnames: true`; see
[sign-in addresses and regions](/docs/operations/deployment/hosts-and-regions).
## How a hostname goes live [#how-a-hostname-goes-live]
1. [`add`](#add) claims the hostname and returns two DNS records: a TXT record that proves the organization controls
the name, and (when the deployment sets `hosts.cnameTarget`) a CNAME that sends its traffic to the deployment.
2. The organization publishes both records with its DNS provider.
3. [`verify`](#verify) looks the TXT record up. Once it matches, the hostname belongs to the organization and to no
other, and requests on it are pinned to the organization, like its subdomain.
4. Optionally, [`setPrimary`](#setprimary) makes it the address sign-in URLs and email links use.
Your TLS termination must also serve a certificate for the hostname. With on-demand certificates (Caddy's `ask`,
Cloudflare for SaaS, and similar), let `iam.hosts.allowed(hostname)` decide: it is true only for verified hostnames
of active organizations. Passkeys are bound to one domain, so they are not offered on a custom hostname outside the
deployment's passkey domain; people sign in there with the other methods.
| Method | What it does | Access |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`add`](#add) | Claims a hostname for the organization and returns the DNS records that verify and route it. | Credential |
| [`delete`](#delete) | Releases a hostname; a verified one stops resolving to the organization at once. | Credential |
| [`list`](#list) | Lists the organization's claimed hostnames, newest first, with their status and DNS records. | Credential |
| [`setPrimary`](#setprimary) | Makes a verified hostname the organization's canonical sign-in address, or goes back to its subdomain. | Credential |
| [`verify`](#verify) | Looks up the hostname's TXT record and, when it matches, marks the hostname verified so it starts resolving to the organization. | Credential |
## add [#add]
Claims a hostname for the organization and returns the DNS records that verify and route it.
**HTTP:** `POST /api/iam/hostnames/add` (requires a credential) · **Browser client:** `client.hostnames.add()`
* **Permission:** `iam:hostnames:create` on the tenant.
* **Audited as:** `iam:hostnames:create`, resource `hostnames/{hostname}`.
* **Errors:** `FEATURE_DISABLED` unless `hosts.customHostnames` is on; `INVALID_INPUT` for a malformed name;
`HOSTNAME_NOT_ALLOWED` for a name the deployment uses itself (its base URL, a trusted origin, or its organization
subdomain space); `CONFLICT` (409) when the organization already claimed it; `HOSTNAME_TAKEN` (409) when another
organization verified it; `LIMIT_EXCEEDED` past 20 hostnames per organization.
The name is lowercased and a trailing dot removed. The claim stays `pending`, and resolves to nothing, until
[`verify`](#verify) sees the TXT record: `_better-iam-challenge.{hostname}` (the label follows
`domains.recordName`) with the value `better-iam-hostname={token}`.
```ts
const claimed = await iam.api.hostnames.add(credential, { tenantId, hostname: 'login.acme.com' });
// claimed.dnsRecords.verification: { type: 'TXT', name: '_better-iam-challenge.login.acme.com', value: '…' }
// claimed.dnsRecords.routing: { type: 'CNAME', name: 'login.acme.com', value: 'custom.signin.example.com' }
```
```ts title="Signature"
iam.api.hostnames.add(
credential: CredentialInput,
input: { tenantId: string; hostname: string },
): Promise
```
## delete [#delete]
Releases a hostname; a verified one stops resolving to the organization at once.
**HTTP:** `POST /api/iam/hostnames/delete` (requires a credential) · **Browser client:** `client.hostnames.delete()`
* **Permission:** `iam:hostnames:delete` on the tenant.
* **Audited as:** `iam:hostnames:delete`.
* **Errors:** `NOT_FOUND` when the hostname is not the organization's.
If it was the primary address, sign-in URLs fall back to the organization's subdomain. Remove the DNS records
afterwards, and revoke its TLS certificate if your certificate automation does not.
```ts title="Signature"
iam.api.hostnames.delete(
credential: CredentialInput,
input: { tenantId: string; hostnameId: string },
): Promise<{ deleted: boolean }>
```
## list [#list]
Lists the organization's claimed hostnames, newest first, with their status and DNS records.
**HTTP:** `POST /api/iam/hostnames/list` (requires a credential) · **Browser client:** `client.hostnames.list()`
* **Permission:** `iam:hostnames:read` on the tenant.
* **Audited as:** `iam:hostnames:read`.
Each entry shows `status` (`pending` or `verified`), whether it is `primary`, its sign-in `url`, and the records to
publish, so a settings page can show setup instructions until verification succeeds.
```ts title="Signature"
iam.api.hostnames.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## setPrimary [#setprimary]
Makes a verified hostname the organization's canonical sign-in address, or goes back to its subdomain.
**HTTP:** `POST /api/iam/hostnames/setPrimary` (requires a credential) · **Browser client:** `client.hostnames.setPrimary()`
* **Permission:** `iam:hostnames:update` on the tenant.
* **Audited as:** `iam:hostnames:update`.
* **Errors:** `INVALID_INPUT` when the hostname is not verified yet; `NOT_FOUND` when it is not the organization's.
Sign-in URLs from [`tenants.lookup`](/docs/reference/api/tenants#lookup), `iam.hosts.signInUrl`, and the `signInUrl`
of every email the organization's people receive use the primary hostname. `hostnameId: null` clears it. The result
has the new primary hostname (or `null`) and the organization's `signInUrl`.
```ts
await iam.api.hostnames.setPrimary(credential, { tenantId, hostnameId: claimed.id });
```
```ts title="Signature"
iam.api.hostnames.setPrimary(
credential: CredentialInput,
input: { tenantId: string; hostnameId: string | null },
): Promise<{ primary: PublicHostname | null; signInUrl: string | null }>
```
## verify [#verify]
Looks up the hostname's TXT record and, when it matches, marks the hostname verified so it starts resolving to the organization.
**HTTP:** `POST /api/iam/hostnames/verify` (requires a credential) · **Browser client:** `client.hostnames.verify()`
* **Permission:** `iam:hostnames:update` on the tenant.
* **Audited as:** `iam:hostnames:update`.
* **Errors:** `FEATURE_DISABLED` unless custom hostnames are on; `HOSTNAME_TAKEN` (409) when another organization
verified it first; `HOSTNAME_NOT_ALLOWED` when the deployment's own addresses changed to include it; `NOT_FOUND`
when it is not the organization's.
DNS is queried before the transaction opens, through `domains.resolveTxt` when you configure one (for example DNS
over HTTPS). While the record is not visible yet, the call succeeds with `verified: false` and records
`lastCheckedAt`, so a settings page can poll it. A verified hostname stays verified; to re-prove control, delete it
and claim it again.
```ts title="Signature"
iam.api.hostnames.verify(
credential: CredentialInput,
input: { tenantId: string; hostnameId: string },
): Promise<{ verified: boolean; hostname: PublicHostname }>
```
# identities (/docs/reference/api/identities)
> Identities are the people and service accounts that sign in to a tenant, and this group manages them from invitation to offboarding.
Identities are the people and service accounts that sign in to a tenant, and this group manages them from
invitation to offboarding. Every identity belongs to exactly one tenant, and its email is unique only within that
tenant: another tenant may hold a separate identity with the same address. The group creates people directly or
through email invitations, reads their sessions, groups, and effective roles, changes their profile, status, and
ownership, answers data-subject requests, and removes access cleanly when someone leaves.
Service accounts (`kind: 'service'`) live in the same directory. You create them with
[`serviceAccounts.create`](/docs/reference/api/service-accounts#create), but reading, listing, disabling,
offboarding, and deleting them go through these methods and the same `iam:identities:*` actions. See
[tenants and identities](/docs/guides/concepts/tenants-and-identities) for the model.
## Invitations [#invitations]
An invitation lets an administrator decide what a new member receives, while the member proves they control the
address and chooses their own password. [`invite`](#invite) stores the email, optional roles and groups, and only a
hash of a single-use token, then queues a `member-invitation` email through the
[delivery outbox](/docs/operations/jobs). The token is sealed inside that message, so neither the inviter nor the
API response ever sees it. Your invitation page reads the token from the link and calls the public
[`acceptInvitation`](#acceptinvitation).
* **Lifetime.** An invitation lasts `onboarding.invitationLifetimeMs` (24 hours by default).
[`resendInvitation`](#resendinvitation) issues a new token and lifetime and the earlier link stops working;
[`revokeInvitation`](#revokeinvitation) cancels it. Accepted, revoked, and expired invitations fail with
`INVITATION_INVALID`.
* **Authority.** Roles and groups are checked when you invite and applied when the person accepts, as bindings
under the grant authority you held at invite time. That authority is re-validated at
acceptance: if it was revoked in the meantime, the invitation can no longer be accepted.
* **Delivery.** Invitations need an email delivery callback (`authentication.sendEmail`); without one,
`invite` and `resendInvitation` fail with `DELIVERY_REQUIRED`.
* **Limits.** The plan's member limit is checked at acceptance, not when you invite, so an invitation can fail with
`LIMIT_EXCEEDED` if the tenant filled up in the meantime.
Organization owners are invited differently: [`tenants.create`](/docs/reference/api/tenants#create) sends an owner
invitation for a new tenant.
## Disabling, offboarding, and deleting [#disabling-offboarding-and-deleting]
Four calls take access away, from lightest to heaviest:
| Call | Takes away | Keeps | Typical use |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ----------------------------------------- |
| [`revokeSessions`](#revokesessions) | Sessions, API keys, remembered devices | The account and all its access | A lost device or a suspected stolen token |
| [`setStatus`](#setstatus) `disabled` | The ability to sign in or authenticate; sessions and keys end | Roles, groups, and attributes, which apply again when re-enabled | Leave of absence, an investigation |
| [`offboard`](#offboard) | Every grant: bindings, memberships, packages, activations, relationships, authorities, sessions, keys | A disabled record for retention | Someone leaves |
| [`delete`](#delete) | The record itself, with credentials and factors | A tombstone, so audit records still resolve | After your retention period |
An identity can also carry an `expiresAt` for contractors and temporary accounts. From that moment its credentials
are refused, and the retention worker (`purgeDeleted`) disables it, ends its sessions and activations, and records
`identity:expire`. An expired identity cannot be re-enabled until you extend or clear the date with
[`update`](#update), so nobody quietly turns a contractor back on. See
[time-bound identities](/docs/guides/privileged-access/lifecycle#time-bound-identities).
Two kinds of account are protected throughout. The last active owner of a tenant cannot be disabled, offboarded,
deleted, or demoted (`LAST_OWNER`), and the last active root administrator cannot be disabled, offboarded, or
deleted (`LAST_ROOT_ADMIN`). A root administrator's status, sessions, expiry, sign-in address, and password can be
changed only by a root administrator.
| Method | What it does | Access |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`acceptInvitation`](#acceptinvitation) | Redeems a member invitation: creates the person's account with the invited email, applies the invitation's roles and groups, and signs them in. | Public |
| [`create`](#create) | Creates a person in the tenant, optionally with a password, a manager, and a scheduled deactivation date. | Credential |
| [`createMany`](#createmany) | Creates up to 100 people in one transaction, each with optional attributes, roles, groups, password, and expiry. | Credential |
| [`delete`](#delete) | Removes a person or service account for good, leaving a tombstone so audit records still name who acted. | Credential |
| [`export`](#export) | Returns everything the tenant stores about one identity as JSON, to answer a data-subject access request. | Credential |
| [`get`](#get) | Returns one identity by ID, including the tombstone of a deleted identity. | Credential |
| [`impersonate`](#impersonate) | Opens a short-lived "view as" session as a member, for support and troubleshooting, and returns its token. | Credential |
| [`invite`](#invite) | Invites a person to the tenant by email, with roles and groups they receive when they accept. | Credential |
| [`list`](#list) | Lists the tenant's people and service accounts, with filters and paging. | Credential |
| [`listBindings`](#listbindings) | Lists an identity's role bindings, direct and through its groups, with their activation and window state. | Credential |
| [`listGroups`](#listgroups) | Lists the groups an identity currently belongs to, with `membershipExpiresAt` on temporary memberships. | Credential |
| [`listInvitations`](#listinvitations) | Lists every member invitation of the tenant, pending or not, without tokens. | Credential |
| [`listReports`](#listreports) | Lists the active people whose manager is this identity, by name. | Credential |
| [`listSessions`](#listsessions) | Lists an identity's unexpired sessions and API keys, most recently used first, without token hashes. | Credential |
| [`offboard`](#offboard) | Disables an identity and removes everything that gave it access in one transaction, handing what it owned to a successor. | Credential |
| [`requestPasswordReset`](#requestpasswordreset) | Emails a member a password-reset link on an administrator's behalf. | Credential |
| [`resendInvitation`](#resendinvitation) | Sends a member invitation again with a new token and a fresh lifetime; the earlier link stops working. | Credential |
| [`revokeInvitation`](#revokeinvitation) | Cancels a member invitation so its link can no longer be used. | Credential |
| [`revokeSessions`](#revokesessions) | Ends every session and API key of an identity without disabling it. | Credential |
| [`setBoundary`](#setboundary) | Sets a root-controlled permissions boundary on one identity, capping what it may do in the tenant whatever its roles grant. | Credential |
| [`setOwner`](#setowner) | Makes a member an owner of the tenant, or removes their ownership. | Credential |
| [`setStatus`](#setstatus) | Disables an identity or re-enables it. | Credential |
| [`unlock`](#unlock) | Clears the rate-limit counters that lock a person out of sign-in, recovery, and MFA after too many attempts. | Credential |
| [`update`](#update) | Changes an identity's name, declared attributes, email, manager, or scheduled deactivation. | Credential |
## acceptInvitation [#acceptinvitation]
Redeems a member invitation: creates the person's account with the invited email, applies the invitation's roles and groups, and signs them in.
**HTTP:** `POST /api/iam/identities/acceptInvitation` (no credential) · **Browser client:** `client.identities.acceptInvitation()`
* **Permission:** None: public. The token from the invitation email is the proof.
* **Audited as:** `identity:invitation:accept`, with the new member as the actor and the inviter, roles, and groups
in the metadata.
* **Errors:** `INVITATION_INVALID` when the token is unknown, already used, revoked, or expired, or the inviter's
grant authority was revoked; `TENANT_UNAVAILABLE` when the tenant or one of its ancestors is not active;
`INVALID_INPUT` when neither the call nor the invitation gives a name; `IDENTITY_EXISTS` when an account with that
email was created in the meantime; `LIMIT_EXCEEDED` at the tenant's member limit; `WEAK_PASSWORD` or
`BREACHED_PASSWORD` when the password fails the password rules; `NOT_FOUND` when one of the invitation's roles or
groups was deleted since; `SOD_CONFLICT` when the invitation's roles together break a
[separation-of-duties rule](/docs/guides/authorization/separation-of-duties).
The email counts as verified, because following the link proved control of the address. `name` overrides the name
the inviter suggested. The result is the new public identity plus either `{ token, session }` or an MFA challenge
(`mfaRequired: true`) when the tenant requires MFA; continue with the
[MFA flow](/docs/guides/authentication/mfa#completing-a-challenge). Over HTTP, a response that issues a session
also sets the session cookie. Everything happens in one transaction, so a failure leaves the invitation usable.
```ts
const result = await client.identities.acceptInvitation({
tenantId: params.tenant,
token: params.token,
password: form.password,
});
if ('mfaRequired' in result) {
// enroll or verify the second factor, then continue
}
```
```ts title="Signature"
iam.api.identities.acceptInvitation(
input: { tenantId: string; token: string; name?: string; password: string },
): Promise
```
## create [#create]
Creates a person in the tenant, optionally with a password, a manager, and a scheduled deactivation date.
**HTTP:** `POST /api/iam/identities/create` (requires a credential) · **Browser client:** `client.identities.create()`
* **Permission:** `iam:identities:create` on the tenant.
* **Audited as:** `iam:identities:create`.
* **Errors:** `IDENTITY_EXISTS` (409) when the email is already used in this tenant; `LIMIT_EXCEEDED` at the
tenant's member limit; `WEAK_PASSWORD` or `BREACHED_PASSWORD` for a password the rules refuse; `INVALID_INPUT`
for an `expiresAt` that is not in the future or is more than ten years ahead, or a manager who is not active;
`NOT_FOUND` when the manager is not in this tenant; `INVARIANT_VIOLATION` when the new person would break an
enforced [access invariant](/docs/guides/governance/change-safety).
The account starts active, with an unverified email and no roles. Without a password the person cannot sign in
with one: send them a reset link with [`requestPasswordReset`](#requestpasswordreset), or use [`invite`](#invite)
instead, which lets them choose it. `expiresAt` (epoch milliseconds) schedules deactivation. Declared attributes
are set with [`update`](#update) or [`createMany`](#createmany). After the call commits,
[automatic access-package rules](/docs/guides/privileged-access/access-packages#automatic-assignment) are
reconciled for the new person, so a matching package applies right away.
```ts
const contractor = await iam.api.identities.create(credential, {
tenantId,
email: 'sam@contractor.example',
name: 'Sam Rivera',
managerId: teamLeadId,
expiresAt: Date.parse('2026-12-31T23:59:59Z'),
});
```
```ts title="Signature"
iam.api.identities.create(
credential: CredentialInput,
input: {
tenantId: string;
email: string;
name: string;
password?: string;
expiresAt?: number;
managerId?: string;
},
): Promise
```
## createMany [#createmany]
Creates up to 100 people in one transaction, each with optional attributes, roles, groups, password, and expiry.
**HTTP:** `POST /api/iam/identities/createMany` (requires a credential) · **Browser client:** `client.identities.createMany()`
* **Permission:** `iam:identities:create` on the tenant. With roles, also `iam:bindings:create` on each role and an
active grant authority; with groups, `iam:groups:update` on each group and authority over each of its role
bindings.
* **Audited as:** `iam:identities:create`.
* **Errors:** `INVALID_INPUT` for an empty list, more than 100 entries, or an undeclared or mistyped attribute;
`ACCESS_DENIED` without the right to grant one of the roles or fill one of the groups; `PROTECTED_RESOURCE` for
the Owner role; `GRANT_AUTHORITY_REQUIRED` when roles are given and you hold no active grant authority;
`IDENTITY_EXISTS`, `LIMIT_EXCEEDED`, `SOD_CONFLICT`, and `INVARIANT_VIOLATION` as for single creation. Any
failure rejects the whole batch.
Use it for migrations and cohort onboarding. Roles are bound directly to each person under your grant authority and
group memberships are permanent. An entry's `expiresAt` is that identity's deactivation date, not an expiry for its
grants. Attributes are checked against `permissions.identityAttributes`. The rights to grant each role and fill
each group are checked once for the whole batch, so an import can never grant more than you could bind by hand.
```ts
const { identities } = await iam.api.identities.createMany(credential, {
tenantId,
identities: newHires.map((hire) => ({
email: hire.email,
name: hire.name,
attributes: { department: hire.department }, // declared in permissions.identityAttributes
groupIds: [everyoneGroupId],
})),
});
```
```ts title="Signature"
iam.api.identities.createMany(
credential: CredentialInput,
input: {
tenantId: string;
identities: Array<{
email: string;
name: string;
password?: string;
attributes?: Record;
roleIds?: string[];
groupIds?: string[];
expiresAt?: number;
}>;
},
): Promise<{ identities: PublicIdentity[] }>
```
## delete [#delete]
Removes a person or service account for good, leaving a tombstone so audit records still name who acted.
**HTTP:** `POST /api/iam/identities/delete` (requires a credential) · **Browser client:** `client.identities.delete()`
* **Permission:** `iam:identities:delete` on the identity, with recent authentication.
* **Audited as:** `iam:identities:delete` and `identity:delete` (with the kind and former email).
* **Errors:** `CONFLICT` when the identity is already deleted; `INVALID_INPUT` when you try to delete yourself;
`ACCESS_DENIED` for a root administrator unless you are root; `LAST_OWNER` or `LAST_ROOT_ADMIN` for the last
active owner or root administrator; `RECENT_AUTH_REQUIRED` when your sign-in is not recent.
In one transaction it ends every session, API key, remembered device, and pending challenge; deletes role bindings,
group memberships, activations, package assignments and requests, relationships, boundaries, passkeys, MFA
enrollment, password history, and external-provider mappings; revokes the grant authorities the identity held and
its account links; cancels its pending access requests; and clears it as the manager of anyone who reported to it.
Revoking its grant authorities means grants it issued as a delegated administrator stop applying (see
[grant authorities](/docs/guides/authorization/roles#grant-authorities)).
The tombstone keeps the ID, name, and kind with `status: 'deleted'`. It has no sign-in email, phone, or password;
the former address is kept as `deletedEmail`. `list` leaves tombstones out unless asked, and `get` still returns
them. Unlike `offboard`, deletion hands nothing to a successor, so for leavers call [`offboard`](#offboard) first
and `delete` after your retention period.
```ts title="Signature"
iam.api.identities.delete(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise
```
## export [#export]
Returns everything the tenant stores about one identity as JSON, to answer a data-subject access request.
**HTTP:** `POST /api/iam/identities/export` (requires a credential) · **Browser client:** `client.identities.export()`
* **Permission:** `iam:identities:read` on the identity, with recent authentication. Audit events are included only
when you also hold `iam:audit:read` on the tenant.
* **Audited as:** `iam:identities:read` and `identity:export` (with the kind and whether audit events were
included).
* **Errors:** `RECENT_AUTH_REQUIRED` when your sign-in is not recent; `NOT_FOUND` when the identity is not in this
tenant.
The export contains the public identity; its stored sessions and API keys without token hashes; whether MFA is
enabled; passkey identifiers and transports; external-provider subjects; effective role bindings; groups;
relationships; access requests; boundaries; grant authorities; account links; and SCIM links. With
`iam:audit:read` it adds the audit events the identity performed, newest first and at most 5,000, and
`auditIncluded` tells you which you got. Secrets, password hashes, and tokens are never included. Deleted
identities can be exported too, which helps when a request arrives after the account was removed.
```ts title="Signature"
iam.api.identities.export(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise<{
exportedAt: number;
tenantId: string;
identity: PublicIdentity;
sessions: {
[key: string]: unknown;
identityId: string;
createdAt: number;
expiresAt: number;
lastSeenAt: number;
authenticatedAt: number;
mfa: boolean;
kind: 'user' | 'role' | 'api-key' | 'session-token' | 'delegated';
agentId?: string;
delegationId?: string;
mfaAuthenticatedAt?: number;
credentialAuthorityId?: string;
sessionName?: string;
sourceIdentity?: string;
sessionTags?: Record;
sourcePolicy?: PolicyDocument;
format?: 'jwt';
audience?: string[];
webIdentity?: { providerId: string; issuer: string; subject: string };
method?: AuthMethod;
client?: SessionClientInfo;
name?: string;
description?: string;
impersonatorId?: string;
impersonatorSessionId?: string;
trustedDeviceId?: string;
originalIdentityId?: string;
sourceTenantId?: string;
roleId?: string;
trustId?: string;
sourceSessionId?: string;
sourceAuthorityIds?: string[];
policy?: PolicyDocument;
previousSignIn?: SignInRecord;
id: string;
tenantId: string;
}[];
mfa: { enabled: boolean };
passkeys: { id: string; credentialId: unknown; transports: unknown }[];
externalIdentities: { providerId: unknown; issuer: unknown; subject: unknown }[];
bindings: EffectiveBinding[];
groups: Group[];
relationships: Relationship[];
accessRequests: AccessRequest[];
boundaries: PolicyDocument[];
grantAuthorities: {
id: string;
revoked: boolean;
parentAuthorityId: string | undefined;
}[];
links: { id: string; linkedIdentityId: string; revoked: boolean }[];
scim: {
connectionId: unknown;
externalId: unknown;
userName: unknown;
active: unknown;
}[];
auditIncluded: boolean;
audit: AuditEvent[] | undefined;
}>
```
## get [#get]
Returns one identity by ID, including the tombstone of a deleted identity.
**HTTP:** `POST /api/iam/identities/get` (requires a credential) · **Browser client:** `client.identities.get()`
* **Permission:** `iam:identities:read` on the identity.
* **Audited as:** `iam:identities:read`.
* **Errors:** `NOT_FOUND` when the identity is not in this tenant.
Password hashes are never returned.
```ts title="Signature"
iam.api.identities.get(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise
```
## impersonate [#impersonate]
Opens a short-lived "view as" session as a member, for support and troubleshooting, and returns its token.
**HTTP:** `POST /api/iam/identities/impersonate` (requires a credential) · **Browser client:** `client.identities.impersonate()`
* **Permission:** `iam:identities:impersonate` on the member, with recent authentication, and the tenant's
[authentication policy](/docs/guides/authentication/tenant-policy) must set `allowImpersonation`.
* **Audited as:** `iam:identities:impersonate` and `identity:impersonate` (with the reason, the new session ID, and
its expiry).
* **Errors:** `FEATURE_DISABLED` when the tenant does not allow impersonation; `ACCESS_DENIED` for an owner or root
administrator; `INVALID_INPUT` for yourself, a service account, a missing reason, or a `durationMs` outside one
minute to eight hours; `IMPERSONATION_RESTRICTED` unless you act through an ordinary session of your own;
`MFA_REQUIRED` when the member requires MFA and your session did not complete it; `IP_NOT_ALLOWED` or
`IP_BLOCKED` when the tenant's network rules refuse your address; `RECENT_AUTH_REQUIRED`.
The session lasts `durationMs` (one hour by default) and never outlives your own session. Each operation it
attempts is allowed only when both the member and you may perform it, and it cannot do anything that needs recent
authentication, assume roles, or grant OAuth consent. Every audit record it produces carries `impersonatorId`,
policies see `principal.impersonated`, and the member sees the session in their own session list. Over HTTP the
token is returned in the body only, never as a cookie, so keep it in a separate context such as a dedicated tab.
See [impersonation](/docs/guides/authentication/impersonation).
```ts
const { token, session } = await iam.api.identities.impersonate(credential, {
tenantId,
identityId: memberId,
reason: 'Ticket 4821: export button missing',
durationMs: 30 * 60_000,
});
```
```ts title="Signature"
iam.api.identities.impersonate(
credential: CredentialInput,
input: { tenantId: string; identityId: string; reason: string; durationMs?: number },
): Promise<{
token: string;
session: {
[x: string]: unknown;
identityId: string;
createdAt: number;
expiresAt: number;
lastSeenAt: number;
authenticatedAt: number;
mfa: boolean;
kind: 'user' | 'role' | 'api-key' | 'session-token' | 'delegated';
agentId?: string | undefined;
delegationId?: string | undefined;
mfaAuthenticatedAt?: number | undefined;
credentialAuthorityId?: string | undefined;
sessionName?: string | undefined;
sourceIdentity?: string | undefined;
sessionTags?: Record | undefined;
sourcePolicy?: PolicyDocument | undefined;
format?: 'jwt' | undefined;
audience?: string[] | undefined;
webIdentity?: { providerId: string; issuer: string; subject: string } | undefined;
method?: AuthMethod | undefined;
client?: SessionClientInfo | undefined;
name?: string | undefined;
description?: string | undefined;
impersonatorId?: string | undefined;
impersonatorSessionId?: string | undefined;
trustedDeviceId?: string | undefined;
originalIdentityId?: string | undefined;
sourceTenantId?: string | undefined;
roleId?: string | undefined;
trustId?: string | undefined;
sourceSessionId?: string | undefined;
sourceAuthorityIds?: string[] | undefined;
policy?: PolicyDocument | undefined;
previousSignIn?: SignInRecord | undefined;
id: string;
tenantId: string;
};
identity: PublicIdentity;
}>
```
## invite [#invite]
Invites a person to the tenant by email, with roles and groups they receive when they accept.
**HTTP:** `POST /api/iam/identities/invite` (requires a credential) · **Browser client:** `client.identities.invite()`
* **Permission:** `iam:identities:create` on the tenant. With roles, also `iam:bindings:create` on each role and an
active grant authority; with groups, `iam:groups:update` on each group and authority over each of its role
bindings.
* **Audited as:** `iam:identities:create`.
* **Errors:** `DELIVERY_REQUIRED` without an email delivery callback; `IDENTITY_EXISTS` when the email already
belongs to an identity in this tenant; `PROTECTED_RESOURCE` for the Owner role; `ACCESS_DENIED` without the
right to grant one of the roles or fill one of the groups; `GRANT_AUTHORITY_REQUIRED` when roles or groups are
given and you hold no active grant authority; `NOT_FOUND` for an unknown role or group.
The result has the invitation ID and expiry but never the token, which travels only in the email. `name` is a
suggestion the person can change when accepting. Nothing is granted until acceptance; see
[Invitations](#invitations). Inviting the same address again does not cancel an earlier invitation, so use
[`resendInvitation`](#resendinvitation) for a lost email. If the person already has a disabled account, re-enable
it with [`setStatus`](#setstatus) instead.
```ts
const invitation = await iam.api.identities.invite(credential, {
tenantId,
email: 'alice@example.com',
name: 'Alice Chen',
roleIds: [editorRoleId],
groupIds: [designGroupId],
});
// invitation.expiresAt: when the link stops working
```
```ts title="Signature"
iam.api.identities.invite(
credential: CredentialInput,
input: {
tenantId: string;
email: string;
name?: string;
roleIds?: string[];
groupIds?: string[];
},
): Promise<{
invitationId: string;
email: string;
expiresAt: number;
roleIds: string[];
groupIds: string[];
}>
```
## list [#list]
Lists the tenant's people and service accounts, with filters and paging.
**HTTP:** `POST /api/iam/identities/list` (requires a credential) · **Browser client:** `client.identities.list()`
* **Permission:** `iam:identities:read` on the tenant.
* **Audited as:** `iam:identities:read`.
* **Errors:** `INVALID_INPUT` for an unknown `kind` or `status`, or a `limit` outside 1 to 1,000.
Results are ordered by name, then ID. `kind` (`user` or `service`) and `status` (`active`, `disabled`, `deleted`)
narrow the list; tombstones are left out unless you pass `includeDeleted` or ask for `status: 'deleted'`. `query`
matches the name or email case-insensitively. `expiresBefore` keeps identities whose scheduled deactivation is at
or before that time, including ones already past it. `limit` and `offset` page through the result.
```ts
// Active accounts that end within the next 14 days.
const ending = await iam.api.identities.list(credential, {
tenantId,
status: 'active',
expiresBefore: Date.now() + 14 * 86_400_000,
});
```
```ts title="Signature"
iam.api.identities.list(
credential: CredentialInput,
input: {
tenantId: string;
kind?: Identity['kind'];
status?: Identity['status'];
includeDeleted?: boolean;
query?: string;
expiresBefore?: number;
limit?: number;
offset?: number;
},
): Promise
```
## listBindings [#listbindings]
Lists an identity's role bindings, direct and through its groups, with their activation and window state.
**HTTP:** `POST /api/iam/identities/listBindings` (requires a credential) · **Browser client:** `client.identities.listBindings()`
* **Permission:** `iam:bindings:read` on the identity.
* **Audited as:** `iam:bindings:read`.
* **Errors:** `NOT_FOUND` when the identity is not in this tenant.
Each entry is the binding with its `role` and `via` (`'identity'`, or `{ groupId }` for a group binding).
[Eligible bindings](/docs/guides/privileged-access/elevation) carry `activation` while activated and
`pendingActivation` while a request awaits approval, and bindings with an access window carry `inWindow`.
Future-dated bindings are listed with their start; expired bindings and lapsed memberships are left out. The list
shows what is bound, not a decision: to see why a specific action is allowed, use
[access paths](/docs/guides/governance/access-paths).
```ts title="Signature"
iam.api.identities.listBindings(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise
```
## listGroups [#listgroups]
Lists the groups an identity currently belongs to, with `membershipExpiresAt` on temporary memberships.
**HTTP:** `POST /api/iam/identities/listGroups` (requires a credential) · **Browser client:** `client.identities.listGroups()`
* **Permission:** `iam:groups:read` on the identity.
* **Audited as:** `iam:groups:read`.
* **Errors:** `NOT_FOUND` when the identity is not in this tenant.
Lapsed memberships are left out even before the purge job removes them, so the list matches the groups that
currently give the identity roles.
```ts title="Signature"
iam.api.identities.listGroups(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise<(Group & { membershipExpiresAt?: number })[]>
```
## listInvitations [#listinvitations]
Lists every member invitation of the tenant, pending or not, without tokens.
**HTTP:** `POST /api/iam/identities/listInvitations` (requires a credential) · **Browser client:** `client.identities.listInvitations()`
* **Permission:** `iam:identities:read` on the tenant.
* **Audited as:** `iam:identities:read`.
Each invitation shows the email, suggested name, roles, groups, the inviter, the grant authority its roles will be
issued under (present only when it carries roles or groups), when it was created and expires, and whether it was
`consumed` or `revoked`. An invitation past `expiresAt` that is neither has simply expired;
[`resendInvitation`](#resendinvitation) renews it.
```ts title="Signature"
iam.api.identities.listInvitations(
credential: CredentialInput,
input: { tenantId: string },
): Promise<{
[key: string]: unknown;
email: string;
name?: string;
roleIds: string[];
groupIds: string[];
authorityId?: string;
inviterId: string;
createdAt: number;
expiresAt: number;
consumed: boolean;
revoked?: boolean;
id: string;
tenantId: string;
}[]>
```
## listReports [#listreports]
Lists the active people whose manager is this identity, by name.
**HTTP:** `POST /api/iam/identities/listReports` (requires a credential) · **Browser client:** `client.identities.listReports()`
* **Permission:** `iam:identities:read` on the identity.
* **Audited as:** `iam:identities:read`.
* **Errors:** `NOT_FOUND` when the identity is not in this tenant.
Disabled and deleted reports are left out. Managers are set with `managerId` on [`create`](#create) or
[`update`](#update), and they can approve requests for eligible bindings and access packages that ask for manager
approval (see [approver groups and managers](/docs/guides/privileged-access/elevation#approver-groups-and-managers)).
```ts title="Signature"
iam.api.identities.listReports(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise
```
## listSessions [#listsessions]
Lists an identity's unexpired sessions and API keys, most recently used first, without token hashes.
**HTTP:** `POST /api/iam/identities/listSessions` (requires a credential) · **Browser client:** `client.identities.listSessions()`
* **Permission:** `iam:identities:read` on the identity.
* **Audited as:** `iam:identities:read`.
* **Errors:** `NOT_FOUND` when the identity is not in this tenant.
Use it for device lists and support. Each session shows when it was created, last used, and expires, how it was
established (`method`), whether it completed MFA, the client details recorded at sign-in, and `impersonatorId` for
"view as" sessions. End them with [`revokeSessions`](#revokesessions).
```ts title="Signature"
iam.api.identities.listSessions(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise<{
[key: string]: unknown;
identityId: string;
createdAt: number;
expiresAt: number;
lastSeenAt: number;
authenticatedAt: number;
mfa: boolean;
kind: 'user' | 'role' | 'api-key' | 'session-token' | 'delegated';
agentId?: string;
delegationId?: string;
mfaAuthenticatedAt?: number;
credentialAuthorityId?: string;
sessionName?: string;
sourceIdentity?: string;
sessionTags?: Record;
sourcePolicy?: PolicyDocument;
format?: 'jwt';
audience?: string[];
webIdentity?: { providerId: string; issuer: string; subject: string };
method?: AuthMethod;
client?: SessionClientInfo;
name?: string;
description?: string;
impersonatorId?: string;
impersonatorSessionId?: string;
trustedDeviceId?: string;
originalIdentityId?: string;
sourceTenantId?: string;
roleId?: string;
trustId?: string;
sourceSessionId?: string;
sourceAuthorityIds?: string[];
policy?: PolicyDocument;
previousSignIn?: SignInRecord;
id: string;
tenantId: string;
}[]>
```
## offboard [#offboard]
Disables an identity and removes everything that gave it access in one transaction, handing what it owned to a successor.
**HTTP:** `POST /api/iam/identities/offboard` (requires a credential) · **Browser client:** `client.identities.offboard()`
* **Permission:** `iam:identities:update` on the identity, with recent authentication. Offboarding an owner also
requires you to be an owner of this tenant, signed in to it with your own account, or root. Direct bindings and
group memberships are removed as `bindings.delete` and `groups.removeMember` would, under your grant authority.
* **Audited as:** `iam:identities:update` and `identity:offboard` (with the reason, kind, successor, and every
count).
* **Errors:** `INVALID_INPUT` for yourself, a missing reason, or a successor who is the same identity or not
active; `ACCESS_DENIED` for a root administrator unless you are root, for an owner unless you are an owner or
root, or for a binding or group membership issued under another administrator's grant authority; `LAST_OWNER` or
`LAST_ROOT_ADMIN`; `NOT_FOUND` for an unknown or deleted identity or successor; `RECENT_AUTH_REQUIRED`.
In order, it removes ownership (the protected Owner binding); ends role activations; revokes
[access-package](/docs/guides/privileged-access/access-packages) assignments with the bindings and memberships
they created; deletes the remaining direct bindings and group memberships; deletes relationships; cancels pending
access and package requests; revokes the grant authorities the identity holds, so grants it issued as a delegated
administrator stop applying; moves its reports to the successor; transfers the managed resources it owns to the
successor; ends every session and API key; and disables it. Without a successor, reports are left without a
manager and owned resources are only counted (`resourcesOwned`). The result counts each step, which makes a good
record for auditors.
The identity stays as a disabled record so the audit trail still names who they were; remove it later with
[`delete`](#delete). Package rules owned by the leaver are suspended when their authority is revoked, so hand them
over first. See [offboarding](/docs/guides/privileged-access/lifecycle#offboarding).
```ts
const summary = await iam.api.identities.offboard(credential, {
tenantId,
identityId: leaverId,
reason: 'Left the company (HR-1234)',
successorId: managerId,
});
// summary.bindings, summary.memberships, summary.resourcesReassigned, ...
```
```ts title="Signature"
iam.api.identities.offboard(
credential: CredentialInput,
input: { tenantId: string; identityId: string; reason: string; successorId?: string },
): Promise<{
departmentsReassigned?: number | undefined;
teamsLeft?: number | undefined;
delegationsRevoked?: number | undefined;
agentsUnsponsored?: number | undefined;
agentsReassigned?: number | undefined;
sessions: number;
bindings: number;
memberships: number;
activations: number;
packages: number;
relationships: number;
accessRequests: number;
authorities: number;
resourcesReassigned: number;
resourcesOwned: number;
reportsReassigned: number;
identity: PublicIdentity;
}>
```
## requestPasswordReset [#requestpasswordreset]
Emails a member a password-reset link on an administrator's behalf.
**HTTP:** `POST /api/iam/identities/requestPasswordReset` (requires a credential) · **Browser client:** `client.identities.requestPasswordReset()`
* **Permission:** `iam:identities:update` on the member, with recent authentication. For an owner you must be
another owner of the same tenant or root; for a root administrator, root.
* **Audited as:** `iam:identities:update` and `identity:password-reset`.
* **Errors:** `FEATURE_DISABLED` when email delivery or password sign-in is not configured; `INVALID_INPUT` for a
service account, a disabled identity, or one without an email; `ACCESS_DENIED` for an owner or root
administrator you may not control; `RECENT_AUTH_REQUIRED`.
Unlike the public `auth.requestPasswordReset`, it works whether or not the address is verified, which makes it the
way to onboard someone created without a password. It returns `{ queued: true, email }`; the reset token goes only
to the member's inbox. Whoever controls a password reset controls the account, so the owner and root rules keep
`iam:identities:update` alone from taking over a more powerful account. See
[recovery](/docs/guides/authentication/recovery#resetting-on-someones-behalf).
```ts title="Signature"
iam.api.identities.requestPasswordReset(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise<{ queued: boolean; email: string | undefined }>
```
## resendInvitation [#resendinvitation]
Sends a member invitation again with a new token and a fresh lifetime; the earlier link stops working.
**HTTP:** `POST /api/iam/identities/resendInvitation` (requires a credential) · **Browser client:** `client.identities.resendInvitation()`
* **Permission:** `iam:identities:update` on the invitation.
* **Audited as:** `iam:identities:update`.
* **Errors:** `CONFLICT` when the invitation was already accepted or revoked; `DELIVERY_REQUIRED` without an email
delivery callback; `NOT_FOUND` when the invitation is not in this tenant.
Use it when the first email expired, was lost, or went to spam: expired invitations can be resent. The new email
names you as the inviter, while the invitation keeps its original inviter, roles, groups, and grant authority.
```ts title="Signature"
iam.api.identities.resendInvitation(
credential: CredentialInput,
input: { tenantId: string; invitationId: string },
): Promise<{ invitationId: string; email: string; expiresAt: number }>
```
## revokeInvitation [#revokeinvitation]
Cancels a member invitation so its link can no longer be used.
**HTTP:** `POST /api/iam/identities/revokeInvitation` (requires a credential) · **Browser client:** `client.identities.revokeInvitation()`
* **Permission:** `iam:identities:update` on the invitation.
* **Audited as:** `iam:identities:update`.
* **Errors:** `CONFLICT` when the invitation was already accepted or revoked; `NOT_FOUND` when it is not in this
tenant.
The invitation stays in [`listInvitations`](#listinvitations) with `revoked: true`, and a revoked invitation cannot
be re-sent.
```ts title="Signature"
iam.api.identities.revokeInvitation(
credential: CredentialInput,
input: { tenantId: string; invitationId: string },
): Promise<{
revoked: boolean;
email: string;
name?: string;
roleIds: string[];
groupIds: string[];
authorityId?: string;
inviterId: string;
createdAt: number;
expiresAt: number;
consumed: boolean;
id: string;
tenantId: string;
}>
```
## revokeSessions [#revokesessions]
Ends every session and API key of an identity without disabling it.
**HTTP:** `POST /api/iam/identities/revokeSessions` (requires a credential) · **Browser client:** `client.identities.revokeSessions()`
* **Permission:** `iam:identities:update` on the identity, with recent authentication.
* **Audited as:** `iam:identities:update` and `identity:revoke-sessions` (with the number revoked).
* **Errors:** `ACCESS_DENIED` for a root administrator unless you are root; `NOT_FOUND` when the identity is not in
this tenant; `RECENT_AUTH_REQUIRED`.
Use it for incident response or a lost device. Remembered devices and pending sign-in challenges are cleared too,
so the next sign-in needs the second factor again, and role sessions assumed from the identity and "view as"
sessions opened through its sessions end as well. The account and its access stay, so a person can sign in again
at once. For a service account this deletes its API keys; issue new ones with
[`credentials.create`](/docs/reference/api/credentials#create). The result's `revoked` is the number of session
records the identity held.
Pass `keepApiKeys: true` to end everything except the API keys: user sessions, role sessions the identity assumed in
other tenants, session tokens (including those minted from its keys), remembered devices, and pending challenges end,
while the keys keep working. Use it when a service account's session tokens may have leaked but its keys have not.
The audit event records `keptApiKeys`. A value other than `true` or `false` is `INVALID_INPUT`.
```ts title="Signature"
iam.api.identities.revokeSessions(
credential: CredentialInput,
input: { tenantId: string; identityId: string; keepApiKeys?: boolean },
): Promise<{ revoked: number }>
```
## setBoundary [#setboundary]
Sets a root-controlled permissions boundary on one identity, capping what it may do in the tenant whatever its roles grant.
**HTTP:** `POST /api/iam/identities/setBoundary` (requires a credential) · **Browser client:** `client.identities.setBoundary()`
* **Permission:** `iam:boundaries:update` on the identity, and you must be a root administrator.
* **Audited as:** `iam:boundaries:update`.
* **Errors:** `ACCESS_DENIED` for anyone but root; `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE`
for a document the catalog rejects; `NOT_FOUND` when the identity is not in this tenant; `INVARIANT_VIOLATION`
when the change would break an enforced access invariant.
A boundary never grants: an action is allowed only when a role grants it and the
boundary allows it too, and boundaries set on the tenant and its ancestors apply on top. Each identity has at most
one boundary per tenant, and calling again replaces it. Boundaries are platform controls, which is why tenant
administrators cannot set them. See [boundaries](/docs/guides/authorization/policies#boundaries).
```ts
// A vendor account may never reach beyond support tickets, whatever roles it is given.
await iam.api.identities.setBoundary(rootCredential, {
tenantId,
identityId: vendorId,
document: {
version: 1,
statements: [{ effect: 'allow', actions: ['tickets:*'], resources: ['*'] }],
},
});
```
```ts title="Signature"
iam.api.identities.setBoundary(
credential: CredentialInput,
input: { tenantId: string; identityId: string; document: PolicyDocument },
): Promise<{
id: string;
tenantId: string;
uniqueKey: string;
identityId: string;
document: PolicyDocument;
}>
```
## setOwner [#setowner]
Makes a member an owner of the tenant, or removes their ownership.
**HTTP:** `POST /api/iam/identities/setOwner` (requires a credential) · **Browser client:** `client.identities.setOwner()`
* **Permission:** `iam:identities:update` on the member, with recent authentication, and you must be an owner of
this tenant yourself (signed in to it with your own account, not through an assumed role) or root.
* **Audited as:** `iam:identities:update`.
* **Errors:** `ACCESS_DENIED` when you are not an owner or root; `INVALID_INPUT` for a service account, a disabled
member, or a non-boolean `owner`; `LAST_OWNER` when removing the last active owner; `RECENT_AUTH_REQUIRED`.
Ownership is the protected [Owner role](/docs/guides/authorization/roles#the-owner-role), which allows every action
in the tenant and cannot be bound, edited, or requested any other way. Granting it binds the Owner role under a new,
unrestricted grant authority delegated from the one you grant under, so the new owner can administer and delegate
like you, within your authority chain; if an authority above theirs is revoked (offboarding or deleting you
revokes yours), their grants stop applying. Removing ownership deletes the Owner binding but leaves the person's
grant authorities, so grants they issued keep applying; revoke those with
[`authorities.revoke`](/docs/reference/api/authorities#revoke) if they should not.
```ts title="Signature"
iam.api.identities.setOwner(
credential: CredentialInput,
input: { tenantId: string; identityId: string; owner: boolean },
): Promise
```
## setStatus [#setstatus]
Disables an identity or re-enables it.
**HTTP:** `POST /api/iam/identities/setStatus` (requires a credential) · **Browser client:** `client.identities.setStatus()`
* **Permission:** `iam:identities:update` on the identity, with recent authentication.
* **Audited as:** `iam:identities:update`.
* **Errors:** `INVALID_INPUT` for a status other than `active` or `disabled`; `ACCESS_DENIED` for a root
administrator unless you are root; `LAST_OWNER` or `LAST_ROOT_ADMIN` when disabling the last active owner or
root administrator; `INVALID_TRANSITION` (409) when re-enabling an identity whose `expiresAt` has passed;
`NOT_FOUND` for a deleted identity; `INVARIANT_VIOLATION` when the change would break an enforced access
invariant.
Disabling ends every session and API key at once, and the identity can no longer sign in or authenticate. Its
roles, groups, and attributes are kept and apply again when you re-enable it, but ended sessions and keys do not
come back. It works for people and service accounts alike
([`serviceAccounts.setStatus`](/docs/reference/api/service-accounts#setstatus) is the service-account
equivalent). Access-package rules are reconciled after the change and never assign anything to a disabled
identity. To remove access for good, use [`offboard`](#offboard).
```ts title="Signature"
iam.api.identities.setStatus(
credential: CredentialInput,
input: { tenantId: string; identityId: string; status: 'active' | 'disabled' },
): Promise
```
## unlock [#unlock]
Clears the rate-limit counters that lock a person out of sign-in, recovery, and MFA after too many attempts.
**HTTP:** `POST /api/iam/identities/unlock` (requires a credential) · **Browser client:** `client.identities.unlock()`
* **Permission:** `iam:identities:update` on the identity, with recent authentication.
* **Audited as:** `iam:identities:update` and `identity:unlock` (with `supported` and `cleared`).
* **Errors:** `RECENT_AUTH_REQUIRED`; `NOT_FOUND` for an unknown or deleted identity.
It resets the counters kept for the identity's email, phone, and ID across sign-in, sign-up, re-authentication,
email verification and change, phone verification, password reset and change, passwordless, passkey, and
second-factor flows. Counters kept per client address and
[network blocks](/docs/reference/api/security#unblocknetwork) are not affected. A custom limiter without a `reset`
method returns `{ supported: false, cleared: 0 }`; otherwise `cleared` is the number of counters reset. See
[lockouts](/docs/guides/authentication/recovery#lockouts).
```ts title="Signature"
iam.api.identities.unlock(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise<{ supported: boolean; cleared: number }>
```
## update [#update]
Changes an identity's name, declared attributes, email, manager, or scheduled deactivation.
**HTTP:** `POST /api/iam/identities/update` (requires a credential) · **Browser client:** `client.identities.update()`
* **Permission:** `iam:identities:update` on the identity. An email change also needs recent authentication, and
for an owner or root administrator the same control as [`requestPasswordReset`](#requestpasswordreset); changing
a root administrator's expiry needs root.
* **Audited as:** `iam:identities:update`, plus `identity:email-change` (with the old and new address) when the
email changes.
* **Errors:** `INVALID_INPUT` when no field is given, for an undeclared or mistyped attribute, an `expiresAt` that
is not in the future or is more than ten years ahead, an email on a service account, or a manager who is the
identity itself, is not active, or reports to the identity (directly or further down); `IDENTITY_EXISTS` when the
new email is taken in this tenant; `LAST_OWNER` when setting an expiry on the last active owner;
`ACCESS_DENIED` for a protected account you may not change; `NOT_FOUND` for a deleted identity;
`INVARIANT_VIOLATION` when the change would break an enforced access invariant.
Only the fields you pass change. `attributes` replaces the whole set of declared attributes, validated against
`permissions.identityAttributes`. A new email is marked unverified and every session ends, because whoever
controls the sign-in address controls the account. `expiresAt: null` clears a scheduled deactivation and
`managerId: null` removes the manager; to end access immediately, disable the identity instead of setting an
expiry. After the change, automatic access-package rules are reconciled for the identity, so new attribute values
can change which packages it receives.
```ts
await iam.api.identities.update(credential, {
tenantId,
identityId,
attributes: { department: 'finance', level: 3 },
managerId: newManagerId,
expiresAt: null, // no longer a temporary account
});
```
```ts title="Signature"
iam.api.identities.update(
credential: CredentialInput,
input: {
tenantId: string;
identityId: string;
name?: string;
attributes?: Record;
email?: string;
expiresAt?: number | null;
managerId?: string | null;
},
): Promise
```
# impact (/docs/reference/api/impact)
> Impact previews show who would gain or lose which actions before you edit a role or policy or delete a role.
Impact previews show who would gain or lose which actions before you edit a role or policy or delete a role. A
role edit reaches everyone who holds the role, directly, through groups, and through every role that inherits it,
and the effect depends on conditions, ceilings, and boundaries that are hard to reason about by reading documents.
A preview answers "what happens if I make this change?" with the real evaluator, and tells you which
[access invariants](/docs/reference/api/invariants) the change would break or fix, without saving anything. The
guide is [change safety](/docs/guides/governance/change-safety).
## How a preview works [#how-a-preview-works]
1. The server finds the affected roles: the changed role, or every role that attaches the changed policy, plus every
role that inherits them, transitively.
2. It collects the holders: active identities (people and service accounts) bound to those roles, directly or
through a live group membership, up to 200.
3. It evaluates each holder against each of your 1 to 10 `resources`, for every known action or the `actions` you
list, then applies the change exactly as the real call would (same validation, same permission, same edit
rights) and evaluates again.
4. It compares the invariants before and after, and rolls the transaction back.
Because the ordinary evaluator runs, conditions, authority ceilings, boundaries, access windows, and just-in-time
eligibility all count. An eligible holder without a live activation holds nothing before or after the change, so
they show no difference. The change is simulated, not made, so enforced invariants are reported here rather than
refused.
| Method | What it does | Access |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
| [`preview`](#preview) | Simulates a role update, a policy document change, or a role deletion, and reports the actions each holder would gain and lose per resource, plus the access invariants the change would break or fix. | Credential |
## preview [#preview]
Simulates a role update, a policy document change, or a role deletion, and reports the actions each holder would gain and lose per resource, plus the access invariants the change would break or fix.
**HTTP:** `POST /api/iam/impact/preview` (requires a credential) · **Browser client:** `client.impact.preview()`
* **Permission:** `iam:policies:simulate` on the tenant, plus what the real change needs: `iam:roles:update` or
`iam:roles:delete` on the role, or `iam:policies:update` on the policy, and the edit rights of the grant
authority that created it (or root).
* **Audited as:** `iam:policies:simulate`. The simulated change is not audited, because it never happens.
* **Errors:** `INVALID_INPUT` when `change` does not name exactly one of `role`, `policy`, or `deleteRole`, when
`resources` does not hold 1 to 10 entries, or when `actions` does not hold 1 to 200; `INVALID_ACTION` for an
action missing from the catalog; `ACCESS_DENIED` when you lack the permission or edit rights the change needs;
`IMPERSONATION_RESTRICTED` from a "view as" session; `NOT_FOUND` when the role, policy, or a managed resource does
not exist; and any error the real call would raise, such as `RESOURCE_IN_USE` for deleting a role that others
inherit, `PROTECTED_RESOURCE`, or `INVALID_POLICY`.
`change` takes one of three shapes: `{ role: { roleId, ...update } }` with the fields
[`roles.update`](/docs/reference/api/roles#update) accepts, `{ policy: { policyId, document } }` for a new policy
document, or `{ deleteRole: roleId }`. `assumeMfa: true` evaluates holders as MFA-verified.
The result lists the affected `roles`, the number of holders `evaluated` (with `truncated: true` when more than
200 were skipped), and `identities`: only the holders whose access changes, each with `changes` per resource
(`gained` and `lost` action names). `gainedTotal` and `lostTotal` sum them up, and `invariants` lists those the
change would newly break (`broken`, with the new violations) or make pass again (`fixed`).
```ts
const preview = await iam.api.impact.preview(credential, {
tenantId,
change: { role: { roleId: approver.id, permissions: ['payments:read', 'payments:approve'] } },
resources: [{ type: 'ledger', id: 'main' }],
});
// preview.identities: [{ identity: { id, name }, changes: [{ resource: 'ledger/main', gained: [...], lost: [...] }] }]
// preview.invariants.broken: guardrails the change would break
```
```ts title="Signature"
iam.api.impact.preview(
credential: CredentialInput,
input: {
tenantId: string;
change: ImpactChange;
resources: { type: string; id: string }[];
actions?: string[];
assumeMfa?: boolean;
},
): Promise
```
# API reference (/docs/reference/api)
> Every method of the Better IAM server API: 45 groups and 417 methods, what each does, its HTTP route, and its TypeScript signature.
`betterIam(options)` returns one object that is your whole identity server. You create it once, usually in
`lib/iam.ts`, export it as `iam`, and import it wherever server code needs to check access, serve the IAM routes, or
run maintenance. This reference covers all of it: the `api` groups that manage organizations, people, and access
(listed under Groups), and the functions on the instance itself (listed under Instance functions).
| Member | What it is for |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `api` | Every provisioning and authentication method, by group: `iam.api.groups.addMember(credential, input)`. The HTTP routes and the browser client call the same methods. |
| `authorize`, `authorizeMany`, `listAccessible`, `require`, `authenticate` | The checks your own routes, pages, and jobs run before they touch product data. |
| `callPlugin` | Calls a plugin endpoint through the same authorized, audited envelope as built-in methods. |
| `handler`, `nodeHandler` | The HTTP transports. Mount one of them at `basePath` (default `/api/iam`); see [HTTP handlers](#http-handlers). |
| `events` | `subscribe(patterns, handler)` for in-process audit subscribers, and `dispatch()` to run them (the same function as `dispatchAuditHooks`). |
| `useProtocol`, `protocolHost` | Mount OAuth, SAML, and SCIM services, and the host callbacks those packages are built from. |
| `initialize`, `bootstrap`, `recoverRoot`, `rotateSecrets`, `selfCheck` | Deployment operations: schema, the first administrator, break-glass recovery, secret rotation, and health findings. |
| `purgeDeleted`, `sweepExpired`, `reconcilePackages`, `sendAccessDigest`, `sendExpiryReminders`, `closeOverdueCertifications`, `checkInvariants`, `archiveAudit`, `pruneAudit`, `dispatchAuditHooks`, `flushAccessUsage` | Scheduled jobs and shutdown work. See [Scheduled jobs](/docs/operations/jobs). |
| `assertionKey`, `assertionKeys` | The keys downstream services verify [assertions](/docs/reference/api/assertions#issue) with. |
| `auth` | Low-level authentication primitives for trusted integrations, such as `dispatchOutbox()` and `withClient()`. |
| `store` | The storage adapter you passed as `database`. |
| `metrics`, `sessionTokens`, `endpoint` | Prometheus-style metrics (with `observability.metrics`), session JWT keys and online verification (with `sts.jwt`), and where the handler is mounted (origin, `basePath`, cookie settings) for framework integrations. |
Keep the instance on the server. `auth`, `store`, `bootstrap`, `recoverRoot`, `assertionKey`, and `protocolHost` act
with the deployment's authority rather than a caller's permissions. The HTTP router leaves them out on purpose, and you
should never expose them through RPC reflection or send them to a browser.
## Three ways to call it [#three-ways-to-call-it]
Every group method is called three ways: on the server as `iam.api.{group}.{method}(credential, input)`, from the typed browser client as `client.{group}.{method}(input)`, and over HTTP as `POST {basePath}/{group}/{method}` with a JSON body. The default `basePath` is `/api/iam`. The first server argument, the credential, says who is calling: a session token, an API key, or the incoming request headers (which carry the session cookie or an `Authorization: Bearer` token).
Over HTTP every call is a `POST` with `Content-Type: application/json` and the `X-Better-IAM: 1` header (anything else is refused with `CSRF_REJECTED`). Requests that carry cookies must also send an exact trusted `Origin`. Responses use a `{ "data": … }` or `{ "error": { "code", "message" } }` envelope; the browser client unwraps it for you. Root bootstrap, recovery, raw storage, and session-issuance primitives are never exposed over HTTP.
> **OpenAPI 3.1 specification.**
The HTTP API is also described as an [OpenAPI 3.1 document](/openapi.json), generated from the same TypeScript types as this reference, with JSON Schemas for every request and response. Import it into Postman, Insomnia, or Bruno, or feed it to a client generator for languages other than TypeScript.
**Server:**
```ts
import { iam } from './iam';
// The first argument is the caller: a session token, an API key, or request headers.
const credential = { headers: request.headers };
const group = await iam.api.groups.create(credential, { tenantId, name: 'Finance' });
```
**Browser client:**
```ts
import { createIamClient } from 'better-iam/client';
import type { iam } from './iam';
const client = createIamClient({ baseURL: 'https://identity.example.com' });
const group = await client.groups.create({ tenantId, name: 'Finance' }); // the session cookie is the credential
```
**HTTP:**
```bash
curl -X POST https://identity.example.com/api/iam/groups/create \
-H "Authorization: Bearer $BETTER_IAM_TOKEN" \
-H "Content-Type: application/json" \
-H "X-Better-IAM: 1" \
-d '{ "tenantId": "ten_…", "name": "Finance" }'
```
## HTTP handlers [#http-handlers]
`iam.handler(request)` serves the Fetch API (a `Request` in, a `Response` out) and `iam.nodeHandler(req, res)` serves
Node's `http` interface. They route identically, so mount exactly one of them. Both need Node.js 22.12 or later; edge
runtimes are not supported. They answer:
* `POST {basePath}/{group}/{method}` for every routed group method, plus `POST {basePath}/authorize`,
`/authorizeMany`, `/listAccessible`, and `/plugins/{pluginId}/{path}` for plugin endpoints;
* `GET {basePath}/health` (one database read, status 503 when storage fails), `GET {basePath}/metrics` (only with
`observability.metrics.bearerToken`), and `GET {basePath}/.well-known/jwks.json` (only with `sts.jwt`);
* the routes of every protocol service mounted with [`useProtocol`](#useprotocol), which are asked first.
`nodeHandler` is required when you mount the OAuth authorization server, which only speaks Node's request and
response objects. It offers each request to the Node protocol mounts first and hands everything else to `handler`.
Both enforce the browser boundary: JSON bodies with `X-Better-IAM: 1`, an exact trusted `Origin` for requests that
carry cookies (`CSRF_REJECTED` or `UNTRUSTED_ORIGIN` otherwise), and API bodies of at most 64 KiB
(`PAYLOAD_TOO_LARGE`). They set the session cookie on sign-in routes and clear it on sign-out, add CORS headers for
trusted origins, echo a plain `X-Request-Id`, and return every error as `{ "error": { "code", "message" } }`. The
framework packages ([Next.js](/docs/frameworks/nextjs), [NestJS](/docs/frameworks/nestjs),
[Node middleware](/docs/frameworks/node)) wrap them for you.
```ts
import { createServer } from 'node:http';
import { iam } from './lib/iam';
// A dedicated identity server: IAM routes, health, metrics, and every mounted protocol.
createServer((req, res) => iam.nodeHandler(req, res)).listen(3000);
// Or, inside a Fetch-style router running on Node (Hono shown):
app.all('/api/iam/*', (c) => iam.handler(c.req.raw));
```
## When to call each function [#when-to-call-each-function]
| When | Functions |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| In the request path | `authenticate`, `authorize`, `require`, `authorizeMany`, `listAccessible`, `callPlugin` |
| Once at process start | `useProtocol`, `iam.events.subscribe` |
| Once per deploy or installation | `initialize` (CLI `migrate`), `bootstrap`, `selfCheck` (CLI `doctor`) |
| On a schedule | `dispatchAuditHooks`, `purgeDeleted`, `sweepExpired`, `reconcilePackages`, `sendAccessDigest`, `sendExpiryReminders`, `closeOverdueCertifications`, `checkInvariants`, `archiveAudit`, `pruneAudit` |
| Rarely, by an operator | `recoverRoot`, `rotateSecrets`, `assertionKey`, `assertionKeys` |
| At shutdown | `flushAccessUsage` |
Deployment operations and jobs take no credential. They act as `deployment-operator`, the actor you see on the audit
events they record, so protect the process and configuration that runs them like a root credential. Every job is safe
to run again and to overlap with itself; each function below says how it avoids doing work twice, and
[`selfCheck`](#selfcheck) reports the jobs that have stopped running. Most jobs have a matching
[CLI command](/docs/reference/cli) for cron.
## Groups [#groups]
### Sign-in and people [#sign-in-and-people]
Authenticate people and machines, manage who exists in a tenant, and how they are organized.
- [auth](/docs/reference/api/auth): End-user authentication: sign-in methods, MFA, passkeys, sessions, devices, and account recovery. (40 methods)
- [identities](/docs/reference/api/identities): People in a tenant: invitations, profiles, attributes, sessions, offboarding, and data-subject export. (24 methods)
- [teams](/docs/reference/api/teams): Nested teams with maintainers, join requests, and access through a team-managed group. (27 methods)
- [departments](/docs/reference/api/departments): The org chart: departments with heads, one department per person, import from attributes, heads as managers. (14 methods)
- [serviceAccounts](/docs/reference/api/service-accounts): Service accounts for machines, with scheduled deactivation. (6 methods)
- [credentials](/docs/reference/api/credentials): API keys with scopes, labels, expiry, and last-use tracking. (6 methods)
- [links](/docs/reference/api/links): Explicit account linking between identities, and switching between linked accounts. (4 methods)
- [domains](/docs/reference/api/domains): Verified email domains for discovery and single sign-on routing. (5 methods)
- [security](/docs/reference/api/security): Network blocks for addresses and ranges. (3 methods)
### Organizations [#organizations]
The tenant tree, organization addresses and settings, and platform administration.
- [tenants](/docs/reference/api/tenants): Tenant trees: organizations, settings, authentication and access policies, limits, and usage. (19 methods)
- [hostnames](/docs/reference/api/hostnames): Custom hostnames organizations verify with DNS and use as their own sign-in address. (5 methods)
- [features](/docs/reference/api/features): Feature flags at platform and organization level: definitions, targets, overrides, and evaluation. (8 methods)
- [onboarding](/docs/reference/api/onboarding): Onboarding checklists for newcomers and new tenants, customized at platform, organization, and project level. (15 methods)
- [root](/docs/reference/api/root): Platform root administration. (2 methods)
- [trust](/docs/reference/api/trust): Cross-tenant trust for platform-controlled role assumption. (6 methods)
- [sts](/docs/reference/api/sts): Temporary security credentials: short-lived sessions for assumed roles, with a duration, session policies, and tags. (3 methods)
- [oidcProviders](/docs/reference/api/oidc-providers): Trusted external OpenID Connect providers whose tokens can be exchanged for temporary credentials. (6 methods)
### Access model [#access-model]
What can be done to what, and who holds which permissions.
- [roles](/docs/reference/api/roles): Custom roles built from permissions or conditional policies, inheritance, and role assumption. (9 methods)
- [policies](/docs/reference/api/policies): Versioned JSON policies, candidate testing, simulation, and who-can / what-can reviews. (11 methods)
- [bindings](/docs/reference/api/bindings): Role bindings for identities and groups, including temporary, future-dated, and just-in-time eligible bindings. (12 methods)
- [groups](/docs/reference/api/groups): Groups and (optionally temporary) group memberships. (10 methods)
- [authorities](/docs/reference/api/authorities): Delegated grant authorities: who may hand out which roles below them. (2 methods)
- [resourceTypes](/docs/reference/api/resource-types): Tenant-defined resource types with actions, typed attributes, and relations. (5 methods)
- [actions](/docs/reference/api/actions): Tenant-defined actions added to the permission catalog. (3 methods)
- [resources](/docs/reference/api/resources): IAM-managed resources with owners and parents. (6 methods)
- [relationships](/docs/reference/api/relationships): Relationship tuples (ReBAC) that policies read as `resource.relations` . (3 methods)
### Access lifecycle [#access-lifecycle]
Granting access for a purpose and a time, and reporting on it.
- [packages](/docs/reference/api/packages): Access packages: bundles of roles and memberships assigned, requested, or granted by rule. (18 methods)
- [accessRequests](/docs/reference/api/access-requests): Time-boxed access requests that reviewers approve under their own authority. (7 methods)
- [accessPaths](/docs/reference/api/access-paths): Self-service answers to "how can I get access?": the requests, packages, and elevations that would grant an action. (1 methods)
- [reports](/docs/reference/api/reports): The access report: what ends soon, unused keys, live activations, and pending requests. (1 methods)
- [config](/docs/reference/api/config): Configuration as code: export, plan, and apply a tenant’s access model by name. (3 methods)
### Governance [#governance]
Reviewing, analyzing, and constraining access over time.
- [certifications](/docs/reference/api/certifications): Access certification campaigns: reviewers keep or revoke access with evidence. (9 methods)
- [analysis](/docs/reference/api/analysis): Access-analysis findings (dormant access, stale keys, policy lint, separation of duties) and suppressions. (4 methods)
- [roleMining](/docs/reference/api/role-mining): Role mining: bundle suggestions, right-sizing, peer outliers, and usage. (6 methods)
- [sod](/docs/reference/api/sod): Separation-of-duties rules enforced when access is granted. (5 methods)
- [invariants](/docs/reference/api/invariants): Access invariants: guardrails that must hold whatever roles and policies say. (5 methods)
- [impact](/docs/reference/api/impact): Change-impact previews: who gains and loses access before a role, policy, or binding changes. (1 methods)
- [agreements](/docs/reference/api/agreements): Versioned terms of use that members accept and policies can require. (7 methods)
### Events and integrations [#events-and-integrations]
The audit log, outgoing events, and service-to-service trust.
- [audit](/docs/reference/api/audit): The tamper-evident audit log: search, chain verification, and JSON Lines export. (3 methods)
- [webhooks](/docs/reference/api/webhooks): Signed webhook subscriptions, filters, delivery history, and redelivery. (9 methods)
- [assertions](/docs/reference/api/assertions): Short-lived signed assertions for calling downstream services without sharing sessions. (1 methods)
### AI agents and models [#ai-agents-and-models]
Agents as accounts, what they may do for people, and governed access to models.
- [agents](/docs/reference/api/agents): AI agents as accounts: sponsors, ceilings, and a kill switch. (14 methods)
- [delegations](/docs/reference/api/delegations): Agents acting on a person's behalf, within a scope and for a time. (15 methods)
- [inference](/docs/reference/api/inference): Model access, provider keys, budgets, and metering. (16 methods)
### Billing [#billing]
What people, teams, and organizations spend, and the budgets and statements around it.
- [billing](/docs/reference/api/billing): Spend tracking for people, teams, and organizations: meters, prices, usage, budgets, credits, and statements. (38 methods)
## Instance functions [#instance-functions]
Functions on the object `betterIam()` returns, outside the `api` groups. Checks such as `authorize` and `require` are what your routes call; deployment operations (migrations, scheduled jobs, secret rotation) have no HTTP routes, so you run them from a worker, a cron job, or the [CLI](/docs/reference/cli).
| Function | What it does |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| [`archiveAudit`](#archiveaudit) | Copies every tenant's new audit events, verified and in chain order, to the configured `auditArchive` sink. |
| [`assertionKey`](#assertionkey) | Returns the key downstream services use to verify the assertions this deployment issues. |
| [`assertionKeys`](#assertionkeys) | Returns the assertion key of the current secret followed by those of `previousSecrets`, for verifiers during a secret rotation. |
| [`authenticate`](#authenticate) | Resolves a credential to the identity and session behind it, re-checking everything that could have revoked it. |
| [`authorize`](#authorize) | Decides whether the caller may perform one action on one resource and returns the decision instead of throwing. |
| [`authorizeMany`](#authorizemany) | Evaluates up to 50 checks for one caller in one tenant in a single transaction, for rendering UI state. |
| [`bootstrap`](#bootstrap) | Creates the platform's root tenant and its first root administrator, once per installation. |
| [`callPlugin`](#callplugin) | Runs a plugin endpoint from server code inside the same authorized, audited transaction as a built-in method. |
| [`checkInvariants`](#checkinvariants) | Evaluates the access invariants of every active organization and records an audit event when one breaks or recovers. |
| [`closeOverdueCertifications`](#closeoverduecertifications) | Closes every auto-closing certification campaign whose due date has passed and applies its decisions. |
| [`closeOverdueTeamReviews`](#closeoverdueteamreviews) | Completes every open team membership review past its due date, applying its removals (a scheduler job). |
| [`dispatchAuditHooks`](#dispatchaudithooks) | Runs plugin `afterAudit` hooks, `events.onEvent`, and in-process subscribers for audit events that have committed. |
| [`flushAccessUsage`](#flushaccessusage) | Writes the access usage buffered in memory to storage now. |
| [`initialize`](#initialize) | Creates or upgrades the database schema and runs one-time data upgrades so the instance can serve requests. |
| [`listAccessible`](#listaccessible) | Lists the registered resources of one managed type that the caller may perform an action on, one page at a time. |
| [`pruneAudit`](#pruneaudit) | Deletes one tenant's audit events older than a retention period while keeping the rest of the chain verifiable. |
| [`purgeDeleted`](#purgedeleted) | Runs the retention worker: ends access whose time is up and removes deleted tenants past their retention window. |
| [`reconcilePackages`](#reconcilepackages) | Applies access-package rules: people who match a rule receive the package, and automatic holders who stopped matching lose it. |
| [`recoverRoot`](#recoverroot) | Creates an additional root administrator in the root tenant when nobody can sign in as root any more. |
| [`require`](#require) | Checks one action like `authorize` and throws `ACCESS_DENIED` (403) when it is not allowed. |
| [`rotateSecrets`](#rotatesecrets) | Re-seals values encrypted with a previous deployment secret using the current one, so the old secret can be retired. |
| [`selfCheck`](#selfcheck) | Reports configuration and storage problems an operator should act on, each with a severity and a fix. |
| [`sendAccessDigest`](#sendaccessdigest) | Emails each organization's owners its access report when the report has something in it, at most once per interval. |
| [`sendExpiryReminders`](#sendexpiryreminders) | Emails each person whose access ends soon one reminder listing it, once per item and end date. |
| [`sweepExpired`](#sweepexpired) | Deletes records nothing reads any more (expired sessions, protocol artifacts, old deliveries) so storage stops growing with traffic. |
| [`useProtocol`](#useprotocol) | Mounts a protocol service (OAuth sign-in, the OAuth authorization server, SAML, or SCIM) so the HTTP handlers route its paths to it. |
### archiveAudit [#archiveaudit]
Copies every tenant's new audit events, verified and in chain order, to the configured `auditArchive` sink.
* **When:** on a schedule, every few minutes and at least hourly.
* **CLI:** [`audit-archive`](/docs/reference/cli#audit-archive).
* **Permission:** none. A deployment operation.
* **Audited as:** not audited. Progress is kept in one archive cursor per tenant.
* **Errors:** `NO_AUDIT_ARCHIVE` (501) when no `auditArchive` is configured; `INVALID_INPUT` for `limit` outside 1 to
10,000,000.
* **Safe to repeat:** yes. One run at a time holds a tenant through a renewable lease; a tenant another run holds is
skipped and listed under `busy`.
Each run reads the events after each tenant's cursor in batches, verifies every batch against the hash the previous
one ended with, hands it to your sink's `write`, and moves the cursor only after `write` resolves. A crash can
therefore hand the sink the same range again, so a sink must key batches by tenant and sequence range and throw
rather than replace a stored batch with different content; `createJsonlAuditArchive` does both (see
[continuous audit archiving](/docs/operations/jobs#continuous-audit-archiving)).
Problems do not throw. They are reported per tenant under `failed` with the code `AUDIT_CHAIN_BROKEN` (the chain does
not verify, and nothing past the break is archived), `ARCHIVE_WRITE_FAILED`, or `ARCHIVE_CONFLICT`, and sequences
deleted before they were archived appear under `gaps`. A run archives at most `limit` events (default 100,000);
`truncated: true` means run it again.
```ts
const result = await iam.archiveAudit();
if (result.failed.length) alerts.error('Audit archive is failing', result.failed);
```
```ts title="Signature"
iam.archiveAudit(
input?: { tenantId?: string; limit?: number } | undefined,
): Promise
```
### assertionKey [#assertionkey]
Returns the key downstream services use to verify the assertions this deployment issues.
* **When:** when you configure a downstream service. Never send it to a browser.
* **Permission:** none. A deployment secret, never exposed over HTTP.
[Assertions](/docs/reference/api/assertions#issue) are short-lived HS256 JSON Web Tokens that describe the caller
to another service. The key is a SHA-256 derivation of the deployment `secret`, as 64 hex characters: a service that
holds it verifies assertions with `verifyAssertion`, without a database round trip, and cannot recover the secret.
HS256 is symmetric, so whoever holds the key can also mint assertions that every other holder accepts: give it only
to services you trust and store it like a secret. It changes when `secret` changes; during a rotation, hand out
`assertionKeys()` instead.
```ts
// In the IAM deployment, once, to configure the reports service:
const reportsKey = iam.assertionKey();
// In the reports service, which holds only that key:
import { verifyAssertion } from 'better-iam';
const claims = verifyAssertion(token, { key: process.env.IAM_ASSERTION_KEY!, audience: 'reports' });
```
```ts title="Signature"
iam.assertionKey(): string
```
### assertionKeys [#assertionkeys]
Returns the assertion key of the current secret followed by those of `previousSecrets`, for verifiers during a secret rotation.
* **When:** while the deployment secret rotates.
* **Permission:** none. Deployment secrets, never exposed over HTTP.
New assertions are always signed with the current `secret`, but a verifier configured before a switch must accept
tokens signed on either side of it while configurations roll out. `verifyAssertion`, the NestJS assertion module, and
the Next.js edge verifier all accept this list. Hand it to verifiers when you introduce the new secret, and replace it
with `assertionKey()` alone when you retire `previousSecrets` (see
[rotating the deployment secret](/docs/operations/deployment/secrets)). If the old secret leaked, never give its key
to new verifiers.
```ts
// In the IAM deployment: every key, current first, for the downstream secret store.
const keys = iam.assertionKeys();
// In the reports service, while the rotation is in progress:
import { verifyAssertion } from 'better-iam';
const claims = verifyAssertion(token, {
key: process.env.IAM_ASSERTION_KEYS!.split(','),
audience: 'reports',
});
```
```ts title="Signature"
iam.assertionKeys(): string[]
```
### authenticate [#authenticate]
Resolves a credential to the identity and session behind it, re-checking everything that could have revoked it.
* **When:** in the request path, when you need to know who is calling (to render a profile, choose a tenant, or pass
the principal on) without checking a specific permission.
* **Permission:** none. It proves identity, not access.
* **Audited as:** nothing, except `auth:session:mismatch` when a session bound to its sign-in network is presented
from another address.
* **Errors:** `UNAUTHENTICATED` for a missing, malformed, expired, idle, or revoked credential, or a disabled or
expired identity; `MFA_REQUIRED` when the session still owes MFA; `EMAIL_UNVERIFIED` when email verification is
required and missing; `TENANT_UNAVAILABLE` or `TENANT_INACTIVE` when the organization is not active;
`IP_NOT_ALLOWED` or `IP_BLOCKED` when network rules refuse the session.
Pass `{ token }`, or the incoming `{ headers }` (the session cookie or an `Authorization: Bearer` value). Every
credential kind works: user sessions, API keys, assumed-role sessions, session tokens, and IAM-signed session JWTs.
Each is backed by a stored session row, so revoking the row ends the credential at its next use. When you pass
headers, the client address is derived from them as the HTTP handler would (`http.clientInfo`), so network
allowlists and blocks apply. The result is `{ identity, session }`; `session.kind`, `session.mfa`, and
`session.tenantId` describe the caller. It checks no permission, so follow it with `authorize` or `require` before
acting on data. See [sessions](/docs/guides/authentication/sessions).
```ts
const { identity, session } = await iam.authenticate({ headers: request.headers });
return Response.json({ name: identity.name, tenantId: session.tenantId, mfa: session.mfa });
```
```ts title="Signature"
iam.authenticate(
input: CredentialInput,
): Promise
```
### authorize [#authorize]
Decides whether the caller may perform one action on one resource and returns the decision instead of throwing.
* **When:** in the request path, before you read or change a product resource. Also served as
`POST {basePath}/authorize` and `client.authorize(input)`.
* **Permission:** none to call. The credential in the request (`token` or `headers`) is the principal being checked.
* **Audited as:** the requested action, only for denials (outcome `deny`) and root overrides. Allowed checks are not
recorded.
* **Errors:** `UNAUTHENTICATED` when the credential is missing, expired, idle, or revoked; `MFA_REQUIRED` when the
session still owes MFA; `TENANT_UNAVAILABLE` or `TENANT_INACTIVE` when the caller's organization or one of its
ancestors is not active; `NOT_FOUND` for an unknown tenant or a managed resource that is not registered;
`RESOURCE_RESOLVER_REQUIRED` for an application resource type without the `resolveResource` option;
`RESOURCE_MISMATCH` when `resolveResource` returns a different resource.
The result is `{ allowed, reason, matched }`. An allow carries the reason it was granted. Every denial reports
`ACCESS_DENIED` with an empty `matched`, so a caller learns nothing about which rule refused it. The check reads
current state in one transaction (bindings, groups, policies, [conditions](/docs/guides/authorization/conditions),
boundaries, relationships, and the tenant tree), and there is no permission cache to invalidate.
* An action the catalog does not know is denied, not an error.
* A root administrator signed in with MFA is allowed everything (`ROOT_OVERRIDE`), and each override is audited.
* Any other credential only works in its own organization: asking about another `tenantId` is denied.
* An [impersonation](/docs/guides/authentication/impersonation) session is allowed only what both the member and the
administrator behind it may do.
* With the `accessUsage` option on, each allowed check counts as usage for
[role mining](/docs/guides/governance/usage-and-mining).
```ts
const decision = await iam.authorize({
headers: request.headers,
tenantId,
action: 'documents:write',
resource: { type: 'document', id: documentId },
});
if (!decision.allowed) return new Response('Forbidden', { status: 403 });
```
```ts title="Signature"
iam.authorize(
request: AuthorizationRequest,
): Promise
```
### authorizeMany [#authorizemany]
Evaluates up to 50 checks for one caller in one tenant in a single transaction, for rendering UI state.
* **When:** in the request path, typically while a page decides which buttons and menu items to show. Also served as
`POST {basePath}/authorizeMany` and `client.authorizeMany(input)`.
* **Permission:** none to call. Every check is evaluated for the request's credential.
* **Audited as:** each check's action, for denials and root overrides only.
* **Errors:** `INVALID_INPUT` for no checks, more than 50, or a check without `action`, `resource.type`, or
`resource.id`; otherwise the errors of [`authorize`](#authorize).
Results come back in request order as `{ action, resource, allowed, reason }`, with the same rules as `authorize`.
Treat them as advisory: they say what the caller could do when the page rendered, so enforce each action again with
`authorize` or `require` when it is performed. One principal validation and one transaction serve every check, which
is cheaper than separate calls. Denied checks are recorded like any denied `authorize`, so a page that probes many
forbidden actions writes one audit event per denial.
```ts
const { results } = await iam.authorizeMany({
headers: request.headers,
tenantId,
checks: [
{ action: 'documents:write', resource: { type: 'document', id: documentId } },
{ action: 'documents:delete', resource: { type: 'document', id: documentId } },
],
});
const [canEdit, canDelete] = results.map((result) => result.allowed);
```
```ts title="Signature"
iam.authorizeMany(
request: BatchAuthorizationRequest,
): Promise<{ results: BatchResult[] }>
```
### bootstrap [#bootstrap]
Creates the platform's root tenant and its first root administrator, once per installation.
* **When:** once, right after the first `initialize`.
* **CLI:** [`bootstrap`](/docs/reference/cli#bootstrap), which reads the `BETTER_IAM_ROOT_*` environment variables.
* **Permission:** none. A deployment operation, never exposed over HTTP.
* **Audited as:** `root:bootstrap`, by `deployment-operator`.
* **Errors:** `ALREADY_INITIALIZED` (409) when a root tenant exists; `WEAK_PASSWORD` for a password shorter than 12
characters or refused by the password policy, `BREACHED_PASSWORD` when a configured breach check matches it;
`SLUG_TAKEN` when `slug` is in use; `INVALID_INPUT` for a malformed email or an empty name.
* **Safe to repeat:** yes. Every call after the first fails with `ALREADY_INITIALIZED` and changes nothing.
In one transaction it creates the root tenant (named `rootName`, default "Platform", with an optional `slug` alias
for sign-in screens), an identity that owns it and is a root administrator with a verified email, and an
unrestricted grant authority for that identity. The result's `mfaEnrollmentRequired: true` is a reminder that root
administrators always need MFA: the first sign-in enrolls a factor before the account can do anything. Read the
password from a secret store; the CLI takes it from the environment so it never appears in a command line.
```ts
const { tenant, identity } = await iam.bootstrap({
email: 'platform-admin@example.com',
name: 'Platform administrator',
password: process.env.BETTER_IAM_ROOT_PASSWORD!,
slug: 'platform',
});
```
```ts title="Signature"
iam.bootstrap(
input: {
email: string;
name: string;
password: string;
rootName?: string;
slug?: string;
},
): Promise<{ tenant: Tenant; identity: PublicIdentity; mfaEnrollmentRequired: true }>
```
### callPlugin [#callplugin]
Runs a plugin endpoint from server code inside the same authorized, audited transaction as a built-in method.
* **When:** in the request path. The same endpoint is served as `POST {basePath}/plugins/{pluginId}/{path}`, and the
browser client reaches it with `client.$request('plugins/{pluginId}/{path}', input)`.
* **Permission:** the endpoint's declared `action`, on `iam/{tenantId}`.
* **Audited as:** the endpoint's `action`.
* **Errors:** `NOT_FOUND` when no plugin with that id has a `POST` endpoint at `path`; `INVALID_INPUT` when
`input.tenantId` differs from `tenantId` or the endpoint's `validate` rejects the input; `ACCESS_DENIED` without the
action; plus whatever the endpoint's handler throws.
[Plugins](/docs/operations/extensions) add endpoints of their own. `callPlugin` finds the endpoint by `pluginId` and
`path`, runs its `validate` on `input` (with `tenantId` added), and then runs its handler in the operation envelope:
the principal is re-validated, the action is authorized, plugin `beforeOperation` and `afterOperation` hooks run, and
the handler's writes commit together with one audit event, or not at all. The handler's return value is returned
unchanged.
```ts
const project = await iam.callPlugin(
{ headers: request.headers },
{ pluginId: 'projects', path: 'create', tenantId, input: { name: 'Website relaunch' } },
);
```
```ts title="Signature"
iam.callPlugin(
credential: CredentialInput,
input: { pluginId: string; path: string; tenantId: string; input?: unknown },
): Promise
```
### checkInvariants [#checkinvariants]
Evaluates the access invariants of every active organization and records an audit event when one breaks or recovers.
* **When:** on a schedule, hourly and after configuration changes.
* **CLI:** [`monitor-invariants`](/docs/reference/cli#monitor-invariants).
* **Permission:** none. A deployment operation.
* **Audited as:** `invariant:broken` (outcome `deny`) when an invariant starts failing or gains violators, and
`invariant:restored` when it passes again, by `deployment-operator`.
* **Safe to repeat:** yes. The outcome is stored on each invariant (`lastCheck`) and events are recorded only when it
changes; each organization is evaluated in its own transaction.
Enforced invariants already refuse changes that would break them. This job is how `monitor` invariants, and
violations that predate enforcement, reach you: a webhook subscribed to `invariant:*` alerts once per change instead
of every hour. The result is `{ checked, broken, restored }`. For a CI gate that fails a build, use
[`invariants.run`](/docs/reference/api/invariants#run) (CLI `check-invariants`). See
[change safety](/docs/guides/governance/change-safety).
```ts title="Signature"
iam.checkInvariants(
input?: { tenantId?: string } | undefined,
): Promise
```
### closeOverdueCertifications [#closeoverduecertifications]
Closes every auto-closing certification campaign whose due date has passed and applies its decisions.
* **When:** on a schedule, hourly or daily with the other jobs.
* **CLI:** [`close-certifications`](/docs/reference/cli#close-certifications).
* **Permission:** none to call. Revocations run under the campaign creator's grant authority.
* **Audited as:** `certification:auto-close` for each campaign, with the outcome counts, plus `iam:bindings:delete`
for each removed binding, by `deployment-operator`.
* **Errors:** `NOT_FOUND` for an unknown `tenantId`.
* **Safe to repeat:** yes. Each campaign is re-read and closed in its own transaction, so a campaign an administrator
closed meanwhile is left alone and none is closed twice.
Only [campaigns](/docs/guides/governance/certifications) created with `autoClose` and a `dueAt` are affected. Revoked
bindings are removed, and items nobody decided follow the campaign's `undecided` setting. When the creator no longer
exists the campaign still closes, and every revocation is reported as `revocation-failed`. The result is
`{ closed, skipped }`, where `skipped` counts open auto-closing campaigns that are not due yet.
```ts title="Signature"
iam.closeOverdueCertifications(
input?: { tenantId?: string } | undefined,
): Promise
```
### closeOverdueTeamReviews [#closeoverdueteamreviews]
Completes every open team membership review past its due date, applying its removals (a scheduler job).
```ts title="Signature"
iam.closeOverdueTeamReviews(
input?: { tenantId?: string } | undefined,
): Promise<{
completed: number;
removed: number;
failed: Array<{ reviewId: string; message: string }>;
}>
```
### dispatchAuditHooks [#dispatchaudithooks]
Runs plugin `afterAudit` hooks, `events.onEvent`, and in-process subscribers for audit events that have committed.
* **When:** on a schedule, every minute, in the process that registers your subscribers. It is the same function as
`iam.events.dispatch()`.
* **Permission:** none.
* **Errors:** whatever a handler throws. The run stops there and the event stays queued.
* **Safe to repeat:** yes, but delivery is at least once. A throwing handler leaves its event queued, and two
dispatchers running at once can both deliver the same event, so make handlers idempotent by `event.id`.
Handlers never run inside the request. The process that records an audit event queues it in the same transaction,
but only when that process has an `iam.events.subscribe` handler, an `events.onEvent` option, or a plugin with
`afterAudit`. This function delivers the queue oldest first and marks each row delivered, and those rows are shared
by every process. The CLI's `outbox` command dispatches too, but a CLI process only has the hooks in its
configuration file, so events it dispatches never reach subscribers your application registered: dispatch in the
application process instead. Webhooks are separate and travel through the outbox (`iam.auth.dispatchOutbox()`). The
result is `{ dispatched }`. See [lifecycle events](/docs/guides/events/lifecycle-events).
```ts
iam.events.subscribe('iam:identities:*', async (event) => {
await searchIndex.refreshIdentity(event.resourceId);
});
setInterval(() => void iam.dispatchAuditHooks().catch(reportError), 60_000);
```
```ts title="Signature"
iam.dispatchAuditHooks(): Promise<{ dispatched: number }>
```
### flushAccessUsage [#flushaccessusage]
Writes the access usage buffered in memory to storage now.
* **When:** at shutdown (for example on `SIGTERM`). Buffered usage is also written on its own every `flushIntervalMs`
(one minute by default) and when `maxBuffered` pairs are waiting.
* **Permission:** none.
* **Audited as:** not audited.
* **Safe to repeat:** yes. Concurrent calls queue behind one write, and a failed write keeps its batch for the next
attempt before rethrowing.
With the `accessUsage` option on, every allowed `authorize`, `authorizeMany`, `listAccessible`, and provisioning
operation is counted in memory per person and action (root overrides and impersonation excepted) and written in
batches, so no request waits on storage. Without a final flush, the last interval of usage is lost when the process
exits. The result is `{ written }`, the number of person and action pairs written; with tracking off it writes
nothing. See [usage and role mining](/docs/guides/governance/usage-and-mining).
```ts
process.on('SIGTERM', async () => {
await iam.flushAccessUsage();
process.exit(0);
});
```
```ts title="Signature"
iam.flushAccessUsage(): Promise<{ written: number }>
```
### initialize [#initialize]
Creates or upgrades the database schema and runs one-time data upgrades so the instance can serve requests.
* **When:** once per deploy, before the new version serves traffic.
* **CLI:** [`migrate`](/docs/reference/cli#migrate).
* **Permission:** none. A deployment operation, never exposed over HTTP.
* **Errors:** `SCHEMA_VERSION` when the database carries a schema version this release does not support.
* **Safe to repeat:** yes. Every step is idempotent, and instances that migrate at the same time wait for each
other's lock (up to ten minutes) instead of failing.
In order, it applies the storage adapter's schema migrations, runs each plugin's `migrate` in its own transaction,
starts the retention window of tenants deleted before `deletedAt` existed, and chains audit events recorded before
the hash chain existed (once, in timestamp order per tenant). Some upgrades build indexes inside the migration
transaction, which blocks IAM writes (not reads) on large tables while it runs, and the first run after upgrading to
the chained audit log backfills it in one transaction. Plan those deploys for a maintenance window; see
[storage](/docs/operations/storage).
```ts
// In a release step, before the new version starts serving.
await iam.initialize();
```
```ts title="Signature"
iam.initialize(): Promise
```
### listAccessible [#listaccessible]
Lists the registered resources of one managed type that the caller may perform an action on, one page at a time.
* **When:** in the request path, for list pages such as "my workspaces". Also served as
`POST {basePath}/listAccessible` and `client.listAccessible(input)`.
* **Permission:** none to call. Results are filtered for the request's credential.
* **Audited as:** not audited.
* **Errors:** `INVALID_ACTION` for an action the catalog does not know; `INVALID_RESOURCE_TYPE` for an unknown type or
one your application resolves itself; `INVALID_INPUT` for `limit` outside 1 to 1000 or `offset` outside 0 to
1,000,000; `NOT_FOUND` for an unknown tenant; plus the credential errors of [`authorize`](#authorize).
It is the reverse of `authorize`: instead of asking about one resource, it evaluates every
[registered resource](/docs/guides/concepts/resources-and-catalog) of `type` against the caller's grants,
boundaries, and conditions, which are loaded once. `total` counts every accessible resource and `resources` holds
the requested page (`limit` defaults to 100, `offset` to 0) in a stable order. A root administrator sees them all,
an impersonation session sees only what the administrator behind it could reach, and a caller from another
organization gets an empty list. It only works for managed types, because application-resolved resources are not
stored in IAM. Like `authorizeMany`, the list is advisory: check the action again when the person opens an item.
```ts
const { resources, total } = await iam.listAccessible({
headers: request.headers,
tenantId,
action: 'workspaces:read',
type: 'workspace',
limit: 25,
offset: 0,
});
```
```ts title="Signature"
iam.listAccessible(
request: AccessibleResourcesRequest,
): Promise<{ resources: ResourceRecord[]; total: number }>
```
### pruneAudit [#pruneaudit]
Deletes one tenant's audit events older than a retention period while keeping the rest of the chain verifiable.
* **When:** on a schedule that matches your retention policy, after the events were archived or exported.
* **CLI:** [`audit-prune`](/docs/reference/cli#audit-prune).
* **Permission:** none. A deployment operation, with no tenant check.
* **Audited as:** `audit:prune` on the tenant, by `deployment-operator`, with the number deleted, the cutoff, and the
sequence and hash the chain now starts after.
* **Errors:** `INVALID_INPUT` for a missing `tenantId` or a `retentionMs` outside 0 to 100 years.
* **Safe to repeat:** yes. It runs in one transaction; a rerun with the same retention deletes only events that have
aged since, and records nothing when nothing was deleted.
It deletes the longest run of oldest events, in chain order, whose timestamps are before now minus `retentionMs`,
stopping at the first newer event, then appends the `audit:prune` checkpoint so
[chain verification](/docs/guides/events/audit-chain) starts after the pruned prefix. Once a tenant has an archive
cursor, or wherever `auditArchive` is configured, it deletes only events the archive already holds, in every process,
and returns `heldForArchive: true` when it stopped early for that reason. Pruned events are gone from the database,
so archive or export them first.
```ts
const { deleted, heldForArchive } = await iam.pruneAudit({
tenantId,
retentionMs: 365 * 24 * 60 * 60 * 1000,
});
```
```ts title="Signature"
iam.pruneAudit(
input: { tenantId: string; retentionMs: number },
): Promise<{
deleted: number;
prunedThroughSequence?: number;
prunedThroughHash?: string;
heldForArchive?: true;
}>
```
### purgeDeleted [#purgedeleted]
Runs the retention worker: ends access whose time is up and removes deleted tenants past their retention window.
* **When:** on a schedule, hourly or at least daily.
* **CLI:** [`purge`](/docs/reference/cli#purge).
* **Permission:** none. A deployment operation.
* **Audited as:** `identity:expire` for each identity it disables and `tenants:purge` for each purged tenant tree, by
`deployment-operator`. Audit records of purged tenants are kept.
* **Errors:** `INVALID_INPUT` when `retentionMs` is outside 0 to ten years.
* **Safe to repeat:** yes. Authentication bookkeeping is deleted in short batches that re-read each row, and the rest
runs in one transaction, so a second or overlapping run finds nothing left to do.
In one run it:
* deletes expired temporary role bindings with their activations, and ended just-in-time activations;
* removes lapsed temporary group memberships (with the activations of the group's eligible bindings they carried) and
access-package assignments past their end;
* marks pending access requests and package requests past their lifetime as `expired`;
* disables identities past their scheduled deactivation (`expiresAt`), revoking their sessions and activations;
* deletes rate-limit counters past their window, expired challenges, and lapsed network blocks;
* deletes every record of tenants tombstoned more than `retentionMs` ago (default 30 days), their descendants,
plugin-owned records through plugin `purge` callbacks, and references to them from surviving tenants.
Expired access is refused at its next use even before the worker runs, so the schedule only decides how quickly
statuses, lists, and reports catch up. The result counts `purgedTenants`, `deletedRecords`, `expiredBindings`,
`expiredRequests`, `expiredIdentities`, `expiredActivations`, `expiredMemberships`, and `expiredAssignments`. See
[access lifecycle](/docs/guides/privileged-access/lifecycle).
```ts
const result = await iam.purgeDeleted({ retentionMs: 30 * 24 * 60 * 60 * 1000 });
```
```ts title="Signature"
iam.purgeDeleted(
input?: { retentionMs?: number } | undefined,
): Promise
```
### reconcilePackages [#reconcilepackages]
Applies access-package rules: people who match a rule receive the package, and automatic holders who stopped matching lose it.
* **When:** on a schedule, every 15 minutes, after `purgeDeleted`.
* **CLI:** [`reconcile`](/docs/reference/cli#reconcile).
* **Permission:** none to call. Each change runs under the rule owner's grant authority, so a rule never grants more
than its owner could.
* **Audited as:** `package:auto-assign`, `package:auto-ending`, and `package:auto-revoke` for each change, plus
`package:auto-suspended`, `package:auto-braked`, `package:auto-failed`, `package:auto-resumed`, and
`package:auto-confirm` for rule problems and confirmations.
* **Errors:** `INVALID_INPUT` for a `packageId` without `tenantId`, `confirm` without `packageId`, or a `limit`
outside 1 to 10,000; `NOT_FOUND` for an unknown `tenantId`.
* **Safe to repeat:** yes. It plans from plain reads and applies each change in its own transaction; a change whose
inputs moved in between is counted as `stale` and retried on the next run.
This is how [automatic assignment](/docs/guides/privileged-access/automatic-assignment) (birthright access) keeps up
with SCIM provisioning, invitations, attribute changes, and group changes. Removals run first and honor the rule's
grace period (`ending`). A run makes at most `limit` changes per organization (default 1000); `truncated: true` means
run it again. Scheduled runs hold back unusually large changes (listed under `braked`) until a person confirms them in
the console or with `confirm: true` for one `packageId`. A rule that cannot be evaluated is `suspended` instead of
revoking everyone, and an organization that fails is listed under `skipped.failedTenants` without stopping the
others.
```ts
const result = await iam.reconcilePackages();
if (result.failed.length || result.suspended.length || result.braked.length) await notifyAdmins(result);
```
```ts title="Signature"
iam.reconcilePackages(
input?: | { tenantId?: string; packageId?: string; limit?: number; confirm?: boolean }
| undefined,
): Promise
```
### recoverRoot [#recoverroot]
Creates an additional root administrator in the root tenant when nobody can sign in as root any more.
* **When:** during an incident, from a trusted shell with the production configuration.
* **CLI:** [`recover-root`](/docs/reference/cli#recover-root), which reads the `BETTER_IAM_ROOT_*` environment
variables.
* **Permission:** none. A deployment operation, never exposed over HTTP: access to the database and configuration is
the authority.
* **Audited as:** `root:recover`, by `deployment-operator`.
* **Errors:** `NOT_INITIALIZED` before `bootstrap` has run; `IDENTITY_EXISTS` when the email already belongs to an
identity in the root tenant; `WEAK_PASSWORD` or `BREACHED_PASSWORD` for a password the policy refuses.
* **Safe to repeat:** each call adds another administrator and another `root:recover` event.
It does not reset or unlock existing administrators. It adds a new identity with a verified email and the root
administrator flag, which enrolls MFA at its first sign-in, so use an email address that is not in the root tenant
yet. Once you are back in, review the [root administrators](/docs/reference/api/root#listadministrators) and repair
or remove the lost account. Treat it as a break-glass procedure and alert on `root:recover` events.
```ts title="Signature"
iam.recoverRoot(
input: { email: string; name: string; password: string },
): Promise<{ identity: PublicIdentity; tenantId: string; mfaEnrollmentRequired: true }>
```
### require [#require]
Checks one action like `authorize` and throws `ACCESS_DENIED` (403) when it is not allowed.
* **When:** in the request path, as a guard at the top of a route handler or server action.
* **Permission:** none to call. The request's credential is checked.
* **Audited as:** the requested action, for denials and root overrides.
* **Errors:** `ACCESS_DENIED` when the decision is a denial; otherwise the errors of [`authorize`](#authorize).
Use `require` when a denial should end the request, and `authorize` when you want to branch on the decision.
Framework guards and middleware call it for you. Map the thrown `IamError` to a response by its `code`, never its
message.
```ts
await iam.require({
headers: request.headers,
tenantId,
action: 'invoices:approve',
resource: { type: 'invoice', id: invoiceId },
});
```
```ts title="Signature"
iam.require(
request: AuthorizationRequest,
): Promise
```
### rotateSecrets [#rotatesecrets]
Re-seals values encrypted with a previous deployment secret using the current one, so the old secret can be retired.
* **When:** during a secret rotation, after every process runs with the new `secret` and the old value in
`previousSecrets`.
* **CLI:** [`rotate-secrets`](/docs/reference/cli#rotate-secrets).
* **Permission:** none. A deployment operation.
* **Audited as:** not audited.
* **Errors:** `INVALID_INPUT` for a `batchSize` outside 1 to 2000 or a `limit` that is not a positive integer.
* **Safe to repeat:** yes. It works a page at a time in short transactions that re-read each record, so you can stop
it and run it again.
It rewrites TOTP authenticator secrets, webhook signing secrets, and the sealed payloads of undelivered outbox
messages. Sessions, API keys, and invitation links do not depend on the secret, so nobody is signed out.
`dryRun: true` only counts. The result has `resealed` and `unreadable` counts per collection, `current` (values
already sealed with the current secret), `complete`, and `done`. Repeat until `done` is true, wait a day for emailed
links and assertions issued under the old secret to expire, then remove `previousSecrets` everywhere.
`unreadable` values open with no configured secret, which means the secret was replaced without listing the old one:
put it back into `previousSecrets` and run again. See
[rotating the deployment secret](/docs/operations/deployment/secrets).
```ts
const preview = await iam.rotateSecrets({ dryRun: true });
if (!preview.done) await iam.rotateSecrets();
```
```ts title="Signature"
iam.rotateSecrets(
options?: SecretRotationOptions | undefined,
): Promise
```
### selfCheck [#selfcheck]
Reports configuration and storage problems an operator should act on, each with a severity and a fix.
* **When:** after each deploy as a gate, and on a schedule as a health check. It only reads.
* **CLI:** [`doctor`](/docs/reference/cli#doctor).
* **Permission:** none. A deployment operation.
* **Errors:** `INVALID_INPUT` for `cap` outside 1 to 100,000 or a retention option out of range.
It returns `{ ok, findings, storage }`. `ok` is false when any finding is an `error`. `storage` is the adapter's own
description: schema version, applied migrations, record counts, and durability settings. Each finding has a stable
`check` id, a `severity`, a `message`, a `fix`, and sometimes a `count`:
* **Errors:** `schema-behind`, `not-bootstrapped`, `sqlite-durability` (a rollback journal with `synchronous` at
NORMAL or OFF), and `unreadable-secrets`.
* **Warnings:** `in-memory-database`, `postgres-async-commit`, `weak-secret`, `weak-metrics-token`,
`no-email-transport`, `secret-rotation-pending`, `secret-rotation-unverified`, and jobs that are not running:
`sweep-backlog` (records due for more than two days), `purge-not-running` (expired bindings, memberships, or
challenges over a day old), `outbox-stalled` (messages waiting over 15 minutes), `outbox-abandoned` (messages
abandoned in the last day), `audit-hooks-stalled` (hooks waiting over 15 minutes), and `audit-archive-behind`
(unarchived events older than a day, with `auditArchive`).
* **Info:** `storage-undescribed` and `previous-secrets-configured`.
Pass the `deliveryRetentionMs` and `graceMs` your sweep runs with so its backlog is judged the same way. `cap`
(default 1000) bounds how many records each check counts and how many sealed values per collection it samples.
```ts
const { ok, findings } = await iam.selfCheck({ deliveryRetentionMs: 30 * 24 * 60 * 60 * 1000 });
if (!ok) throw new Error(findings.map((finding) => `${finding.check}: ${finding.fix}`).join('\n'));
```
```ts title="Signature"
iam.selfCheck(
options?: SelfCheckOptions | undefined,
): Promise
```
### sendAccessDigest [#sendaccessdigest]
Emails each organization's owners its access report when the report has something in it, at most once per interval.
* **When:** on a schedule, daily, followed by an outbox run that delivers the messages.
* **CLI:** [`digest`](/docs/reference/cli#digest).
* **Permission:** none. A deployment operation that includes every report section.
* **Audited as:** `tenant:access-digest` on each organization emailed, by `deployment-operator`, with the recipient
and finding counts.
* **Errors:** `DELIVERY_REQUIRED` when `authentication.sendEmail` is not configured; `NOT_FOUND` for an unknown
`tenantId`; `INVALID_INPUT` for a window out of range.
* **Safe to repeat:** yes. An organization digested within `minimumIntervalMs` (default 20 hours) is skipped, so
reruns and overlapping schedules never email twice.
For every active organization, or one `tenantId`, it builds the
[access report](/docs/guides/privileged-access/access-report): identities and temporary bindings ending within
`withinMs` (default 30 days), memberships ending soon, live just-in-time activations, pending activation requests,
and API keys unused for `unusedForMs` (default 30 days) or ending soon. Each active owner with an email address of an
organization with findings is sent one `access-digest` message with the counts and the full report as JSON. The
result lists `sent` per organization and `skipped` counts (`inactive`, `recent`, `quiet`, `noOwners`).
```ts
await iam.sendAccessDigest({ withinMs: 14 * 24 * 60 * 60 * 1000 });
await iam.auth.dispatchOutbox();
```
```ts title="Signature"
iam.sendAccessDigest(
input?: | {
tenantId?: string;
withinMs?: number;
unusedForMs?: number;
minimumIntervalMs?: number;
}
| undefined,
): Promise
```
### sendExpiryReminders [#sendexpiryreminders]
Emails each person whose access ends soon one reminder listing it, once per item and end date.
* **When:** on a schedule, daily beside `sendAccessDigest`, followed by an outbox run.
* **CLI:** [`remind`](/docs/reference/cli#remind).
* **Permission:** none. A deployment operation.
* **Audited as:** `identity:expiry-reminder` on each person emailed, by `deployment-operator`, with the reminded items.
* **Errors:** `DELIVERY_REQUIRED` when `authentication.sendEmail` is not configured; `NOT_FOUND` for an unknown
`tenantId`; `INVALID_INPUT` for a `withinMs` outside one minute to 365 days.
* **Safe to repeat:** yes. A reminder mark per item and end date prevents repeats.
It looks at every active person in every active organization, or one `tenantId`, and collects what ends within
`withinMs` (default seven days): their own account (`expiresAt`), direct role bindings, temporary group memberships,
and access-package assignments. Bindings and memberships that belong to a package are reminded as the package, group
bindings are left to the owners' digest, and eligible (just-in-time) bindings are skipped. Each person with something
new gets one `expiry-reminder` email with `items` as JSON (kind, name, and end). Extending access brings a fresh
reminder when the new end enters the window. People without an email address are skipped.
```ts
const { sent } = await iam.sendExpiryReminders({ withinMs: 3 * 24 * 60 * 60 * 1000 });
```
```ts title="Signature"
iam.sendExpiryReminders(
input?: { tenantId?: string; withinMs?: number } | undefined,
): Promise
```
### sweepExpired [#sweepexpired]
Deletes records nothing reads any more (expired sessions, protocol artifacts, old deliveries) so storage stops growing with traffic.
* **When:** on a schedule, hourly or daily, beside `purgeDeleted`.
* **CLI:** [`sweep`](/docs/reference/cli#sweep).
* **Permission:** none. A deployment operation.
* **Audited as:** not audited.
* **Errors:** `INVALID_INPUT` for an option outside its range.
* **Safe to repeat:** yes. Each batch is read and deleted in its own short transaction, and records are judged on
their current state, so it can run during traffic and beside another run.
It deletes user sessions, role sessions, and session tokens past their absolute expiry; trusted devices and
relationship tuples past expiry; redeemed web-identity tokens once they could no longer be presented; OAuth artifacts
and login states (OAuth grants 31 days after expiry, so back-channel logout still reaches the client); SAML request,
relay-state, and assertion-replay records; delivered or abandoned outbox messages and abandoned Shared Signals
deliveries older than `deliveryRetentionMs`; and audit hook rows already dispatched. API keys, pending deliveries,
invitations, access requests, usage records, and SCIM connections are never deleted by age.
Options: `limit` (default 10,000 records per run, at most 1,000,000), `batchSize` (500 per transaction, 1 to 5000),
`deliveryRetentionMs` (30 days, 0 to 3650 days), `graceMs` (a five-minute margin past expiry against clock skew, at
most one day), and `now` (for tests and backfills). The result is `{ deleted, total, truncated }` with counts per
collection; `truncated: true` means more records are due, so run it again. Pass the same `deliveryRetentionMs` to
[`selfCheck`](#selfcheck) so its backlog check agrees.
```ts
const { total, truncated } = await iam.sweepExpired({ deliveryRetentionMs: 14 * 24 * 60 * 60 * 1000 });
```
```ts title="Signature"
iam.sweepExpired(
options?: SweepOptions | undefined,
): Promise
```
### useProtocol [#useprotocol]
Mounts a protocol service (OAuth sign-in, the OAuth authorization server, SAML, or SCIM) so the HTTP handlers route its paths to it.
* **When:** once per service at process start, before the handler serves requests.
* **Permission:** none. The mounted service authenticates its own requests.
A protocol service is `{ basePath, handler, nodeHandler }` (either handler optional), as returned by
`createScimService`, `createSamlService`, `createOAuthLogin`, or `createOAuthProvider`, which you build from the
`iam.protocolHost` callbacks. `handler` asks each mounted service in mount order and uses the first response.
`nodeHandler` also offers requests under a service's `basePath` to that service's Node handler, which the OAuth
authorization server requires, so serve it through `iam.nodeHandler`. When a mounted service returns a session (a
SAML or OAuth sign-in), the handler sets the IAM session cookie, and every mount runs with the request's client
address, so network allowlists and blocks apply to the sessions it issues. There is no unmount, so call it once per
service. See [protocol mounts](/docs/operations/deployment/protocol-mounts).
```ts
import { createScimService } from 'better-iam/scim';
const scim = createScimService({ ...iam.protocolHost });
iam.useProtocol(scim);
```
```ts title="Signature"
iam.useProtocol(
protocol: ProtocolService,
): void
```
# inference (/docs/reference/api/inference)
> Decides which people, service accounts, and AI agents may call which AI models, caps what they spend, and meters every call.
Decides which people, service accounts, and AI agents may call which AI models, caps what they spend, and meters every
call. Administrators register upstream providers (whose API keys are sealed and never returned), publish models under
public names, and set budgets. Calling a model is an ordinary policy decision, `inference:invoke` on `model/{name}`, so
roles, conditions, boundaries, and [delegations](/docs/reference/api/delegations) apply as everywhere else. Callers
reach models through the gateway `iam.inference.gateway()` builds, which keeps provider keys on the server, or through
an external gateway that uses `check` and `record`. The group needs the `inference` server option; without it every
method fails with `FEATURE_DISABLED`. The repository guide is `docs/inference.md`.
## Permissions [#permissions]
| Action | Allows |
| ------------------------------------------- | ------------------------------------------------------------------------------ |
| `inference:invoke` on `model/{name}` | Calling a model. |
| `inference:use-tool` on `model-tool/{kind}` | A tool the provider runs itself, for models whose `providerTools` is `policy`. |
| `iam:inference:manage` | Managing providers, models, and budgets. |
| `iam:inference:read` | Listing providers, models, and budgets, and reading usage reports. |
| `iam:inference:record` | Metering calls made for others with check tickets (external gateways). |
The `iam:*` actions are checked on the tenant, except that provider changes and budget deletion are checked on
`iam/{providerId}` and `iam/{budgetId}`. `check`, `listMine`, and `myUsage` need no `iam:*` permission: any credential
may call them for itself.
## Models, inheritance, and policies [#models-inheritance-and-policies]
Models are published per tenant and inherited by sub-tenants: a platform defines providers and models once at the
root tenant and every organization sees them, while an organization's own model of the same name takes precedence for
it. Conditions can use a model's attributes: `resource.provider` (the provider's name), `resource.providerKind`,
`resource.upstreamModel`, `resource.family`, `resource.tier`, `resource.contextWindow`, `resource.inputPricePerMTok`,
`resource.outputPricePerMTok`, and `resource.enabled`.
```json
{
"effect": "allow",
"actions": ["inference:invoke"],
"resources": ["model/*"],
"conditions": { "StringEquals": { "resource.tier": "small" } }
}
```
Tools the provider runs itself (web search, code execution, file search, image generation, computer use, remote MCP
servers) follow the model's `providerTools`:
* `allow` (the default) passes them.
* `deny` refuses any request that asks for one.
* `policy` decides each one as `inference:use-tool` on `model-tool/{kind}`, such as `model-tool/web_search` or
`model-tool/mcp:kb.acme.com`, with `resource.kind` and `resource.host`.
A Responses `prompt` (a stored template that may bring its own tools) counts as the tool `prompt`, and an MCP server
URL that is not in plain canonical form reads as `mcp:invalid`. Functions the caller runs itself are never checked. A
refusal has the reason `TOOL_NOT_ALLOWED` and names the tool.
## Budgets and metering [#budgets-and-metering]
A budget caps tokens (`maxTokens`), cost (`maxCostUsd`), calls (`maxRequests`), or any combination per `minute`,
`hour`, `day`, or `month`, in UTC windows; a `minute` budget is a requests- or tokens-per-minute rate limit.
`subjectType` says whom it covers: `tenant` (every call in the tenant), `group` (the group's members), or `identity`
(one identity; for an agent, everything it does, with its own key and in every delegated session). `scope: 'shared'`
makes one pool for everyone covered, and `scope: 'each'` gives every covered identity the full amount (identity budgets
are always shared). `models` limits a budget to some model name patterns.
A call is refused with the reason `BUDGET_EXCEEDED` when a covering budget is spent or the call's estimate would not
fit in what is left. The first refusal in a window is audited as `inference:budget-exceeded`, and crossing a budget's
`alertAtPercent` is audited once per window as `inference:budget-alert`. Cost is metered in micro-dollars: tokens times
the model's price per million tokens, with cache reads at `cachedInputPricePerMTok` when set and cache writes at the
input price. Budgets count input, output, and cache tokens. Usage records are kept for the `usageRetentionDays` option
(90 days by default) and budget counters for 35 days after their window; the expiry sweep
([`sweepExpired`](/docs/reference/api#sweepexpired)) deletes them after that.
## Gateway and server runtime [#gateway-and-server-runtime]
`iam.inference` is the server-side half, outside the `api` groups:
* `iam.inference.gateway(options)` returns an HTTP handler (a `Request` to `Response` function). It serves Anthropic
Messages (`POST /v1/messages`) and token counting (`POST /v1/messages/count_tokens`, checked but not metered). It
serves OpenAI Chat Completions (`POST /v1/chat/completions`), Responses (`POST /v1/responses`) and Embeddings
(`POST /v1/embeddings`), and `GET /v1/models`. Callers send any Better IAM credential as the API key
(`Authorization: Bearer` or `x-api-key`). For each call the gateway checks `inference:invoke`, the budgets and the
provider-run tools the request asks for. It replaces the public model name with the upstream one, calls the provider
with the sealed key, streams the answer back unchanged, and meters the tokens the provider reports. Refusals use each
API's own error format:
* 401;
* 403 (`ACCESS_DENIED`, `MODEL_DISABLED`, `TOOL_NOT_ALLOWED`);
* 404, including `RESPONSE_NOT_FOUND` for a `previous_response_id` the caller did not create;
* 413 for a body over `maxBodyBytes`;
* 429 (`BUDGET_EXCEEDED`, with `Retry-After` until the window resets);
* 400 when a model is called in the other provider's format, for more than 256 tools or 64 MCP servers, and
`UNSUPPORTED_PARAMETER` for `conversation` or `background` on `/v1/responses` and for references to objects
stored at the provider (`item_reference` inputs, `file_id`s, Anthropic `file` sources, existing containers);
* 502 when the provider cannot be reached.
Options: `basePath`, `fetch`, `timeoutMs` (10 minutes), `anthropicVersion`, `maxBodyBytes` (20 MiB), and `onError`.
* `iam.inference.authorize(credential, { model, tools })` runs the same check for code that calls providers itself and
returns either a permit with the opened provider key (never send it to a client) or `{ denied }`.
* `iam.inference.record(permit, usage)` meters a call made under a permit.
* `iam.inference.models(credential)` lists the models a credential may use, like `listMine`.
```ts
const gateway = iam.inference.gateway({ basePath: '/ai' });
// Hono: app.all('/ai/*', (c) => gateway(c.req.raw));
// Clients: new Anthropic({ baseURL: 'https://app.example.com/ai', apiKey: betterIamToken })
```
| Method | What it does | Access |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`check`](#check) | Tells the caller whether they may invoke a model now and, when they may, returns a single-use ticket for an external gateway. | Credential |
| [`createModel`](#createmodel) | Publishes a model under a public name, served by a provider of this tenant or an ancestor. | Credential |
| [`createProvider`](#createprovider) | Registers an upstream model provider account, whose API key is sealed and never returned. | Credential |
| [`deleteBudget`](#deletebudget) | Deletes a budget together with its usage counters. | Credential |
| [`deleteModel`](#deletemodel) | Removes one of this tenant's models; an inherited model of the same name becomes visible again. | Credential |
| [`deleteProvider`](#deleteprovider) | Deletes a provider that no model uses, together with its sealed key. | Credential |
| [`listBudgets`](#listbudgets) | Lists the tenant's budgets by name, with the current window's standing of each shared pool. | Credential |
| [`listMine`](#listmine) | Lists the enabled models the caller may invoke, for a model picker. | Credential |
| [`listModels`](#listmodels) | Lists every model the tenant can see, its own and inherited ones, by name. | Credential |
| [`listProviders`](#listproviders) | Lists the providers this tenant's models may use: its own, then its ancestors'. | Credential |
| [`myUsage`](#myusage) | Returns the caller's own usage by model and the standing of every budget that covers them. | Credential |
| [`record`](#record) | Meters a call an external gateway made after `check`, by redeeming the check's ticket. | Credential |
| [`setBudget`](#setbudget) | Creates a budget, or replaces one when `budgetId` is given. | Credential |
| [`updateModel`](#updatemodel) | Changes one of this tenant's models, found by its name. | Credential |
| [`updateProvider`](#updateprovider) | Renames a provider, moves its base URL, or replaces its API key. | Credential |
| [`usage`](#usage) | Reports model calls between two times, grouped by identity, agent, model, or day. | Credential |
## check [#check]
Tells the caller whether they may invoke a model now and, when they may, returns a single-use ticket for an external gateway.
**HTTP:** `POST /api/iam/inference/check` (requires a credential) · **Browser client:** `client.inference.check()`
* **Permission:** None: any credential, for itself. The model decision is `inference:invoke` on `model/{name}`.
* **Audited as:** Denials as `inference:invoke` with outcome `deny`, like any decision; the first budget refusal in a
window as `inference:budget-exceeded`.
* **Errors:** `NOT_FOUND` for a model the tenant cannot see; `INVALID_INPUT` for a malformed model name, an
`estimatedTokens` outside 0 to 100 000 000, or `tools` that are not at most 64 provider tool ids;
`FEATURE_DISABLED` without the `inference` option.
The answer is `{ allowed: true, model, budgets, ticket }`, or `{ allowed: false, reason, model }`. The `reason` is one
of:
* `ACCESS_DENIED`;
* `MODEL_DISABLED`: the model or its provider is off;
* `TOOL_NOT_ALLOWED`: with the refused `tool`, when the model's `providerTools` does not allow one of `tools`;
* `BUDGET_EXCEEDED`: with the exhausted `budget`'s standing.
`tools` lists the provider-run tools the call will ask for, as ids such as `web_search` or `mcp:kb.acme.com`
(`providerToolsOf` from `better-iam` reads them from a request body). `estimatedTokens`, priced at the model's input
price, must fit in every covering budget. The
`ticket` is valid for one hour and names the caller, their session, and the model: an external gateway makes the call
and then redeems it with `record`.
```ts
const result = await iam.api.inference.check(credential, { tenantId, model: 'opus', estimatedTokens: 4_000 });
if (!result.allowed) return refuse(result.reason);
// Call the provider, then report the tokens with result.ticket through inference.record.
```
```ts title="Signature"
iam.api.inference.check(
credential: CredentialInput,
input: {
tenantId: string;
model: string;
estimatedTokens?: number;
tools?: string[];
},
): Promise
```
## createModel [#createmodel]
Publishes a model under a public name, served by a provider of this tenant or an ancestor.
**HTTP:** `POST /api/iam/inference/createModel` (requires a credential) · **Browser client:** `client.inference.createModel()`
* **Permission:** `iam:inference:manage` on the tenant.
* **Audited as:** `iam:inference:manage`.
* **Errors:** `CONFLICT` (409) when this tenant already has a model of that name; `NOT_FOUND` when the provider is not
this tenant's or an ancestor's; `INVALID_INPUT` for a malformed name, a `family` or `tier` that is not a short
identifier, a `contextWindow` or `maxOutputTokens` outside 1 to 100 000 000, a price outside 0 to 10 000,
`fallbacks` naming more than five models or the model itself, or a `providerTools` other than `allow`, `deny`, or
`policy`.
The `name` (1 to 128 letters, digits, or `._:/@+-`, starting with a letter or digit) is what callers send and what
policies name as `model/{name}`; `upstreamModel` is the provider's own name for it. Prices are US dollars per million
tokens and drive cost metering and cost budgets; a model without prices costs nothing. `tier` and `family` are
free-form attributes for policies. `maxOutputTokens` caps the output of every gateway call. `fallbacks` lists models
the gateway tries, in order, when this one's provider cannot be reached or answers 429, 500, 502, 503, 504, or 529;
each one only if the caller may call it and it speaks the same wire format. `providerTools` (`allow` by default,
`deny`, or `policy`) governs the tools the provider runs itself (see
[Models, inheritance, and policies](#models-inheritance-and-policies)). A new model starts enabled, and sub-tenants
inherit it.
```ts
await iam.api.inference.createModel(credential, {
tenantId,
name: 'opus',
providerId: anthropic.id,
upstreamModel: 'claude-opus-5-5',
tier: 'frontier',
family: 'claude',
inputPricePerMTok: 5,
outputPricePerMTok: 25,
cachedInputPricePerMTok: 0.5,
});
```
```ts title="Signature"
iam.api.inference.createModel(
credential: CredentialInput,
input: ModelInput & {
tenantId: string;
name: string;
providerId: string;
upstreamModel: string;
},
): Promise
```
## createProvider [#createprovider]
Registers an upstream model provider account, whose API key is sealed and never returned.
**HTTP:** `POST /api/iam/inference/createProvider` (requires a credential) · **Browser client:** `client.inference.createProvider()`
* **Permission:** `iam:inference:manage` on the tenant, and a recent sign-in.
* **Audited as:** `iam:inference:manage`.
* **Errors:** `CONFLICT` (409) for a name another provider of the tenant uses (ignoring case); `ACCESS_DENIED` for a
custom `baseUrl` from anyone but a root administrator, unless the deployment sets `inference.allowCustomBaseUrls`;
`INVALID_INPUT` for an unknown `kind`, an `openai-compatible` provider without `baseUrl`, an `apiKey` that is not 8
to 4096 characters without whitespace, or a `baseUrl` that is not https or carries credentials, a query, or a
fragment; `RECENT_AUTH_REQUIRED` without a recent sign-in.
`kind` is `anthropic`, `openai`, or `openai-compatible` (any endpoint that speaks the OpenAI Chat Completions API).
The first two default to the provider's public endpoint. A custom base URL makes the gateway send the key to that
address, hence the root rule. The key is sealed with the deployment secret, bound to this provider, and opened only
inside the server; the result shows `keyHint`, its last four characters. Models of this tenant and its sub-tenants may
use the provider. [`rotateSecrets`](/docs/reference/api#rotatesecrets) re-seals provider keys under a new secret.
```ts
const anthropic = await iam.api.inference.createProvider(credential, {
tenantId,
name: 'Anthropic',
kind: 'anthropic',
apiKey: process.env.ANTHROPIC_API_KEY!,
});
```
```ts title="Signature"
iam.api.inference.createProvider(
credential: CredentialInput,
input: {
tenantId: string;
name: string;
kind: ProviderKind;
apiKey: string;
baseUrl?: string;
},
): Promise
```
## deleteBudget [#deletebudget]
Deletes a budget together with its usage counters.
**HTTP:** `POST /api/iam/inference/deleteBudget` (requires a credential) · **Browser client:** `client.inference.deleteBudget()`
* **Permission:** `iam:inference:manage` on the budget.
* **Audited as:** `iam:inference:manage`.
* **Errors:** `NOT_FOUND` when the budget is not in this tenant.
Calls it covered are no longer capped by it. Usage records are kept, so reports still show the spending.
```ts title="Signature"
iam.api.inference.deleteBudget(
credential: CredentialInput,
input: { tenantId: string; budgetId: string },
): Promise<{ deleted: boolean }>
```
## deleteModel [#deletemodel]
Removes one of this tenant's models; an inherited model of the same name becomes visible again.
**HTTP:** `POST /api/iam/inference/deleteModel` (requires a credential) · **Browser client:** `client.inference.deleteModel()`
* **Permission:** `iam:inference:manage` on the tenant.
* **Audited as:** `iam:inference:manage`.
* **Errors:** `NOT_FOUND` when this tenant defines no model of that name (an inherited model is deleted in the tenant
that defines it).
Callers asking for the name afterwards get `NOT_FOUND` unless an ancestor defines it. To stop calls without deleting,
use `updateModel` with `enabled: false`.
```ts title="Signature"
iam.api.inference.deleteModel(
credential: CredentialInput,
input: { tenantId: string; name: string },
): Promise<{ deleted: boolean }>
```
## deleteProvider [#deleteprovider]
Deletes a provider that no model uses, together with its sealed key.
**HTTP:** `POST /api/iam/inference/deleteProvider` (requires a credential) · **Browser client:** `client.inference.deleteProvider()`
* **Permission:** `iam:inference:manage` on the provider.
* **Audited as:** `iam:inference:manage`.
* **Errors:** `RESOURCE_IN_USE` (409) while any model uses it, a sub-tenant's included; `NOT_FOUND` when the provider
is not this tenant's own.
Point the models at another provider with `updateModel`, or delete them, first.
```ts title="Signature"
iam.api.inference.deleteProvider(
credential: CredentialInput,
input: { tenantId: string; providerId: string },
): Promise<{ deleted: boolean }>
```
## listBudgets [#listbudgets]
Lists the tenant's budgets by name, with the current window's standing of each shared pool.
**HTTP:** `POST /api/iam/inference/listBudgets` (requires a credential) · **Browser client:** `client.inference.listBudgets()`
* **Permission:** `iam:inference:read` on the tenant.
* **Audited as:** `iam:inference:read`.
Each budget carries `maxCostUsd` next to the stored `maxCostMicros`, `subjectName` (the group's name, or the
identity's email or name), and, for `shared` and `identity` budgets, `standing`: the window's start, `resetsAt`, and
the tokens and cost used and remaining. An `each` budget has one pool per identity; people see theirs with `myUsage`.
```ts title="Signature"
iam.api.inference.listBudgets(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listMine [#listmine]
Lists the enabled models the caller may invoke, for a model picker.
**HTTP:** `POST /api/iam/inference/listMine` (requires a credential) · **Browser client:** `client.inference.listMine()`
* **Permission:** None: any credential, for itself.
* **Audited as:** Not audited; the decisions are evaluated without being recorded.
* **Errors:** `FEATURE_DISABLED` without the `inference` option.
Each model is decided like an `inference:invoke` call, so a delegated agent session sees only what the person may use
within the delegation. Budgets are not considered; `check` does that before a call.
```ts title="Signature"
iam.api.inference.listMine(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listModels [#listmodels]
Lists every model the tenant can see, its own and inherited ones, by name.
**HTTP:** `POST /api/iam/inference/listModels` (requires a credential) · **Browser client:** `client.inference.listModels()`
* **Permission:** `iam:inference:read` on the tenant.
* **Audited as:** `iam:inference:read`.
When a tenant and an ancestor define the same name, only the nearest definition appears. `inherited` marks models an
ancestor defines, and `enabled` is false when the model is disabled or its provider is gone. `provider` names the
provider and its kind; keys never appear.
```ts title="Signature"
iam.api.inference.listModels(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listProviders [#listproviders]
Lists the providers this tenant's models may use: its own, then its ancestors'.
**HTTP:** `POST /api/iam/inference/listProviders` (requires a credential) · **Browser client:** `client.inference.listProviders()`
* **Permission:** `iam:inference:read` on the tenant.
* **Audited as:** `iam:inference:read`.
The tenant's own providers come first (`inherited: false`), each group sorted by name. Each shows `kind`, `baseUrl`,
`keyHint` (the key's last four characters), and `keyRotatedAt`, when the key was last replaced.
```ts title="Signature"
iam.api.inference.listProviders(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## myUsage [#myusage]
Returns the caller's own usage by model and the standing of every budget that covers them.
**HTTP:** `POST /api/iam/inference/myUsage` (requires a credential) · **Browser client:** `client.inference.myUsage()`
* **Permission:** None: any credential, for itself, in its own tenant.
* **Audited as:** Not audited; it only reads.
* **Errors:** `ACCESS_DENIED` when `tenantId` is not the credential's own tenant; `INVALID_INPUT` when `from` is after
`to`.
The report covers `from` (by default the start of this month, UTC) to `to` (by default now). `budgets` lists the
standing of every budget that covers the caller for a model they used in that range or can see, so people can check
what is left of an allowance before they run out.
```ts title="Signature"
iam.api.inference.myUsage(
credential: CredentialInput,
input: { tenantId: string; from?: number; to?: number },
): Promise
```
## record [#record]
Meters a call an external gateway made after `check`, by redeeming the check's ticket.
**HTTP:** `POST /api/iam/inference/record` (requires a credential) · **Browser client:** `client.inference.record()`
* **Permission:** `iam:inference:record` on the tenant, typically held by the gateway's service account.
* **Audited as:** `iam:inference:record`; crossing a budget's `alertAtPercent` also as `inference:budget-alert`.
* **Errors:** `INVALID_TICKET` (400) for an unknown, used, expired, or other-tenant ticket; `INVALID_INPUT` for a token
count outside 0 to 100 000 000.
The ticket supplies the caller as the check saw them (their identity, and the agent and delegation behind a delegated
session) and the model; the gateway reports the token counts from the provider's response (`inputTokens`,
`outputTokens`, and optionally `cacheReadTokens` and `cacheWriteTokens`), plus `status` (`error` for a failed call),
`requestId`, and `latencyMs`. The usage is attributed to that caller and added to every covering budget and delegation
spending cap, even when the caller's session has ended since the check, so a short session cannot make an allowed call
escape its budgets. Each ticket works once. The result is
`{ recorded: true, costMicros }`.
```ts
await iam.api.inference.record(gatewayKey, {
tenantId,
ticket: result.ticket,
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
});
```
```ts title="Signature"
iam.api.inference.record(
credential: CredentialInput,
input: InferenceTokenUsage & {
tenantId: string;
ticket: string;
status?: 'ok' | 'error';
requestId?: string;
latencyMs?: number;
},
): Promise<{ recorded: true; costMicros: number }>
```
## setBudget [#setbudget]
Creates a budget, or replaces one when `budgetId` is given.
**HTTP:** `POST /api/iam/inference/setBudget` (requires a credential) · **Browser client:** `client.inference.setBudget()`
* **Permission:** `iam:inference:manage` on the tenant.
* **Audited as:** `iam:inference:manage`.
* **Errors:** `CONFLICT` (409) for a name another budget of the tenant uses (ignoring case); `NOT_FOUND` for a group
or identity that is not in this tenant (or a deleted identity), or a `budgetId` that is not; `INVALID_INPUT` without
any of `maxTokens`, `maxCostUsd` and `maxRequests`, for a `maxCostUsd` that is not above 0 or is above 10 000, a
`maxTokens` or `maxRequests` below 1, an `alertAtPercent` outside 1 to 100, or a malformed model pattern.
`subjectId` names the group or identity; a `tenant` budget covers the tenant itself. `maxRequests` caps the number of
calls per window, a rate limit for agents that loop. With `budgetId`, fields you leave out keep their stored values,
and `null` clears `maxTokens`, `maxCostUsd`, `maxRequests`, `models`, or `alertAtPercent`. `alertAtPercent` audits
`inference:budget-alert` once per window when usage crosses that share of any limit.
```ts
// Every person gets a million tokens a day.
await iam.api.inference.setBudget(credential, {
tenantId,
name: 'Daily per person',
subjectType: 'tenant',
scope: 'each',
period: 'day',
maxTokens: 1_000_000,
alertAtPercent: 80,
});
// One agent, across its own key and every delegated session.
await iam.api.inference.setBudget(credential, {
tenantId,
name: 'Triage agent',
subjectType: 'identity',
subjectId: agentId,
period: 'month',
maxCostUsd: 200,
});
```
```ts title="Signature"
iam.api.inference.setBudget(
credential: CredentialInput,
input: InferenceBudgetInput & { tenantId: string; budgetId?: string },
): Promise
```
## updateModel [#updatemodel]
Changes one of this tenant's models, found by its name.
**HTTP:** `POST /api/iam/inference/updateModel` (requires a credential) · **Browser client:** `client.inference.updateModel()`
* **Permission:** `iam:inference:manage` on the tenant.
* **Audited as:** `iam:inference:manage`.
* **Errors:** `NOT_FOUND` when this tenant defines no model of that name or the new provider is not visible to it;
`INVALID_INPUT` as for `createModel`.
Fields you leave out keep their values, and `null` clears an optional one. `enabled: false` stops every call to the
model at once (checks answer `MODEL_DISABLED`); new prices apply to calls metered from then on. The name cannot change.
An inherited model changes only in the tenant that defines it; a sub-tenant publishes its own model of the same name
instead.
```ts title="Signature"
iam.api.inference.updateModel(
credential: CredentialInput,
input: ModelInput & { tenantId: string; name: string },
): Promise
```
## updateProvider [#updateprovider]
Renames a provider, moves its base URL, or replaces its API key.
**HTTP:** `POST /api/iam/inference/updateProvider` (requires a credential) · **Browser client:** `client.inference.updateProvider()`
* **Permission:** `iam:inference:manage` on the provider, and a recent sign-in.
* **Audited as:** `iam:inference:manage`.
* **Errors:** `NOT_FOUND` when the provider is not this tenant's own; `CONFLICT` (409) for a name another provider
uses; `ACCESS_DENIED` and `INVALID_INPUT` for `baseUrl` and `apiKey` as in `createProvider`;
`RECENT_AUTH_REQUIRED` without a recent sign-in.
A new `apiKey` is sealed like the first one and updates `keyHint` and `keyRotatedAt`; the next call through the
gateway uses it.
```ts title="Signature"
iam.api.inference.updateProvider(
credential: CredentialInput,
input: {
tenantId: string;
providerId: string;
name?: string;
apiKey?: string;
baseUrl?: string;
},
): Promise
```
## usage [#usage]
Reports model calls between two times, grouped by identity, agent, model, or day.
**HTTP:** `POST /api/iam/inference/usage` (requires a credential) · **Browser client:** `client.inference.usage()`
* **Permission:** `iam:inference:read` on the tenant.
* **Audited as:** `iam:inference:read`.
* **Errors:** `INVALID_INPUT` for an unknown `groupBy` or a `from` after `to`.
`from` defaults to the start of this month (UTC) and `to` to now; `identityId`, `agentId`, and `model` narrow the
records, and `groupBy` defaults to `model`. Each row counts requests, errors, input, output, and cache tokens, and cost
(`costMicros` and `costUsd`), largest cost first, with a `label` (email or name) when grouped by identity or agent;
`totals` adds them up. A call an agent made for a person counts under the person by identity and under the agent by
agent.
```ts
const report = await iam.api.inference.usage(credential, { tenantId, groupBy: 'agent' });
```
```ts title="Signature"
iam.api.inference.usage(
credential: CredentialInput,
input: {
tenantId: string;
from?: number;
to?: number;
identityId?: string;
agentId?: string;
model?: string;
groupBy?: UsageReport['groupBy'];
},
): Promise
```
# invariants (/docs/reference/api/invariants)
> Access invariants are guardrails: statements about who must never, or must always, be able to perform an action on a resource.
Access invariants are guardrails: statements about who must never, or must always, be able to perform an action on
a resource. "Contractors can never approve payments" and "the on-call group can always restart production" should
hold whatever roles, policies, and groups say, but nobody re-checks every such rule by hand after each change. An
invariant writes the rule down once and Better IAM checks it: on demand, on a schedule, and, in `enforce` mode,
around every change to access, refusing any change that would break it. The guide is
[change safety](/docs/guides/governance/change-safety).
## How an invariant is evaluated [#how-an-invariant-is-evaluated]
An invariant names a `subject`, an `action`, a `resource`, and what to `expect`:
* `subject` is exactly one of `{ identityId }`, `{ groupId }` (its live members), `{ attribute: { name, value } }`
(identities whose declared attribute equals the value), or `{ everyone: true }`. Only active identities whose
scheduled deactivation has not passed are evaluated.
* `expect: 'deny'` means nobody in the subject may be allowed; `expect: 'allow'` means everyone in it must be.
* Each person is evaluated like [`policies.simulate`](/docs/reference/api/policies#simulate), with the ordinary
evaluator, so conditions, boundaries, ceilings, relationships, and activations count. `assumeMfa` (default
`true`) evaluates them as MFA-verified, the most they can reach.
A person the evaluation disagrees with is a violation, reported with the decision reason. Reports stop at 500
people per invariant (`truncated: true`); enforcement evaluates everyone. An invariant whose identity, group, or
resource no longer exists reports an `error` instead of a result.
## Monitor and enforce [#monitor-and-enforce]
`monitor` (the default) only reports. `enforce` also guards changes: the tenant's enforced invariants are evaluated
before and after every operation that can change access, including role and policy edits and deletions, binding
changes, just-in-time activation and approval, group membership changes, identity changes, package assignment,
configuration apply, relationship and resource changes, authority revocation, and role assumption. When the
operation newly breaks an invariant, or leaves it impossible to evaluate (for example by deleting the group it
names), it fails with `INVARIANT_VIOLATION` (409) and its transaction rolls back. Violations that already existed do
not block unrelated work, so you can switch an invariant to `enforce` while it is still broken.
Changes made outside the operation envelope, such as scheduled jobs, inbound SCIM provisioning, and members
accepting agreements, are not guarded. Schedule [`iam.checkInvariants`](/docs/reference/api#checkinvariants) (CLI
`monitor-invariants`) to catch those: it stores each invariant's `lastCheck` and records `invariant:broken` and
`invariant:restored` audit events once per change, which a webhook subscribed to `invariant:*` can route.
| Method | What it does | Access |
| ------------------- | -------------------------------------------------------------------------------------------- | ---------- |
| [`create`](#create) | Stores an invariant and returns it with its current result. | Credential |
| [`delete`](#delete) | Deletes an invariant, ending its monitoring and enforcement. | Credential |
| [`list`](#list) | Lists the tenant's invariants by name, with the outcome of the last scheduled check. | Credential |
| [`run`](#run) | Evaluates every invariant, or one, against the current configuration and reports which pass. | Credential |
| [`update`](#update) | Changes any field of an invariant and returns it with its new result. | Credential |
## create [#create]
Stores an invariant and returns it with its current result.
**HTTP:** `POST /api/iam/invariants/create` (requires a credential) · **Browser client:** `client.invariants.create()`
* **Permission:** `iam:invariants:manage` on the tenant.
* **Audited as:** `iam:invariants:manage`.
* **Errors:** `INVALID_INPUT` for a missing or overlong name (100 characters), a `subject` that is not exactly one
of the four shapes, an undeclared identity attribute or a value of the wrong type, or an invalid `expect`, `mode`,
or `assumeMfa`; `INVALID_ACTION` when the action is not in the catalog; `NOT_FOUND` when the named identity,
group, or resource does not exist; `RESOURCE_RESOLVER_REQUIRED` for an application-owned resource type without a
resolver; `CONFLICT` when an invariant with the same name (ignoring case) exists; `LIMIT_EXCEEDED` when the tenant
already has 100 invariants.
The resource must resolve now, because an invariant over a resource that does not exist could never be evaluated.
The returned `result` shows at once whether the invariant holds; creating one in `enforce` mode succeeds even when it
is already broken.
```ts
const { invariant, result } = await iam.api.invariants.create(credential, {
tenantId,
name: 'Contractors never approve payments',
subject: { attribute: { name: 'contractor', value: true } },
action: 'payments:approve',
resource: { type: 'ledger', id: 'main' },
expect: 'deny',
mode: 'enforce',
});
// result.passed, result.violations: [{ identity: { id, name }, reason }]
```
```ts title="Signature"
iam.api.invariants.create(
credential: CredentialInput,
input: InvariantInput & { tenantId: string },
): Promise<{ invariant: AccessInvariant; result: InvariantResult }>
```
## delete [#delete]
Deletes an invariant, ending its monitoring and enforcement.
**HTTP:** `POST /api/iam/invariants/delete` (requires a credential) · **Browser client:** `client.invariants.delete()`
* **Permission:** `iam:invariants:manage` on the invariant (`iam/{invariantId}`).
* **Audited as:** `iam:invariants:manage`.
* **Errors:** `NOT_FOUND` when the invariant is not in this tenant.
An enforced invariant blocks deleting the group or resource it names; delete or change the invariant first.
```ts title="Signature"
iam.api.invariants.delete(
credential: CredentialInput,
input: { tenantId: string; invariantId: string },
): Promise<{ deleted: boolean }>
```
## list [#list]
Lists the tenant's invariants by name, with the outcome of the last scheduled check.
**HTTP:** `POST /api/iam/invariants/list` (requires a credential) · **Browser client:** `client.invariants.list()`
* **Permission:** `iam:invariants:read` on the tenant.
* **Audited as:** `iam:invariants:read`.
Each invariant carries `lastCheck` (`at`, `passed`, the violating identity ids, and an `error` message) once
`iam.checkInvariants` has run. For a fresh evaluation, call `run`.
```ts title="Signature"
iam.api.invariants.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## run [#run]
Evaluates every invariant, or one, against the current configuration and reports which pass.
**HTTP:** `POST /api/iam/invariants/run` (requires a credential) · **Browser client:** `client.invariants.run()`
* **Permission:** `iam:invariants:read` on the tenant, or on the invariant (`iam/{invariantId}`) when you pass
`invariantId`.
* **Audited as:** `iam:invariants:read`.
* **Errors:** `NOT_FOUND` when `invariantId` is not in this tenant.
The result has `generatedAt`, a `summary` (`passed`, `failed`, and `errors` counts), and one entry per invariant
with `passed`, `evaluated`, `truncated`, the `violations`, and an `error` when it could not be evaluated. It changes
nothing: it does not update `lastCheck` or record audit events for broken invariants. In CI, run
[`check-invariants`](/docs/reference/cli#check-invariants) with `--fail-on-broken` after applying configuration.
```ts title="Signature"
iam.api.invariants.run(
credential: CredentialInput,
input: { tenantId: string; invariantId?: string },
): Promise
```
## update [#update]
Changes any field of an invariant and returns it with its new result.
**HTTP:** `POST /api/iam/invariants/update` (requires a credential) · **Browser client:** `client.invariants.update()`
* **Permission:** `iam:invariants:manage` on the invariant (`iam/{invariantId}`).
* **Audited as:** `iam:invariants:manage`.
* **Errors:** `NOT_FOUND` when the invariant is not in this tenant; otherwise the same as `create`.
Fields you leave out keep their values; `description: ''` removes the description. Use it to switch between
`monitor` and `enforce`, or to point an invariant at a new group or resource before deleting the old one.
```ts title="Signature"
iam.api.invariants.update(
credential: CredentialInput,
input: Partial & { tenantId: string; invariantId: string },
): Promise<{ invariant: AccessInvariant; result: InvariantResult }>
```
# links (/docs/reference/api/links)
> Account links connect a person's separate accounts in different tenants so your app can offer an account switcher.
Account links connect a person's separate accounts in different tenants so your app can offer an account switcher.
Identities belong to exactly one tenant, so a consultant working for two clients, or a founder with a personal
workspace, has several accounts with their own passwords, factors, and roles. A link records that the same person
controls both accounts; it never merges them. See
[account linking](/docs/guides/concepts/tenants-and-identities#account-linking).
## What a link does and does not do [#what-a-link-does-and-does-not-do]
A link supplies no permission. Each account keeps its own credentials, MFA, and roles, and holding a session for
one account never opens the other: switching always needs a freshly authenticated credential for the target
account, so its MFA and sign-in rules still apply. The link is what lets a UI list the other accounts and makes
the switch an audited, deliberate step.
Linking is opt-in. It works only when the deployment sets `onboarding: { mode: 'linked' }`; otherwise
[`create`](#create) fails with `LINKING_DISABLED`. Only ordinary user sessions of two different tenants can link.
Root administrators never can, because root authority must not be reachable from another account.
These methods act on the caller's own accounts, so they need no `iam:*` permission. Anyone may call them for
themselves.
| Method | What it does | Access |
| ------------------- | ------------------------------------------------------------------------------------------------------- | ---------- |
| [`create`](#create) | Links the caller's account to another account of theirs in a different tenant, proving control of both. | Credential |
| [`list`](#list) | Lists the accounts linked to the caller's account, for an account-switcher menu. | Credential |
| [`revoke`](#revoke) | Removes a link the person no longer wants. | Credential |
| [`switch`](#switch) | Opens a fresh session for a linked account, using a recently authenticated credential for that account. | Credential |
## create [#create]
Links the caller's account to another account of theirs in a different tenant, proving control of both.
**HTTP:** `POST /api/iam/links/create` (requires a credential) · **Browser client:** `client.links.create()`
* **Permission:** The caller's own session, plus a credential for the target account. Both must be recently
authenticated user sessions.
* **Audited as:** `identity:link`, in the caller's tenant.
* **Errors:** `LINKING_DISABLED` when the deployment does not enable linked onboarding; `RECENT_AUTH_REQUIRED` when
either session is not recently authenticated or is a temporary credential; `IMPERSONATION_RESTRICTED` from a
"view as" session; `INVALID_LINK` when either account is a root administrator, either credential is not a user
session, or both accounts are in the same tenant; `CONFLICT` when the accounts are already linked.
Recent authentication (by default within the last five minutes) on both sides is the proof: the person has just
signed in to each account. Re-linking two accounts whose earlier link was revoked restores that link.
```ts
// The person has just signed in to their other account, for example in a second sign-in form.
const link = await iam.api.links.create(credential, {
targetCredential: { token: otherAccountToken },
});
```
```ts title="Signature"
iam.api.links.create(
credential: CredentialInput,
input: { targetCredential: CredentialInput },
): Promise
```
## list [#list]
Lists the accounts linked to the caller's account, for an account-switcher menu.
**HTTP:** `POST /api/iam/links/list` (requires a credential) · **Browser client:** `client.links.list()`
* **Permission:** The caller's own session.
Each entry has the link `id` and the other account's identity (`identityId`, `name`, `email`, `status`) and tenant
(`tenantId`, `tenantName`, `tenantSlug`, `tenantStatus`). Show the statuses so people understand why a disabled
account or a suspended organization cannot be opened. Revoked links are left out. The call is not audited.
```ts title="Signature"
iam.api.links.list(
credential: CredentialInput,
): Promise
```
## revoke [#revoke]
Removes a link the person no longer wants.
**HTTP:** `POST /api/iam/links/revoke` (requires a credential) · **Browser client:** `client.links.revoke()`
* **Permission:** The caller's own session, recently authenticated, as either side of the link.
* **Audited as:** `identity:unlink`.
* **Errors:** `NOT_FOUND` when the link does not exist or the caller is not one of its two accounts;
`RECENT_AUTH_REQUIRED` without recent authentication.
Either account may revoke the link. The accounts themselves are unaffected, and switching between them stops
working at once.
```ts title="Signature"
iam.api.links.revoke(
credential: CredentialInput,
input: { linkId: string },
): Promise<{ revoked: boolean }>
```
## switch [#switch]
Opens a fresh session for a linked account, using a recently authenticated credential for that account.
**HTTP:** `POST /api/iam/links/switch` (requires a credential) · **Browser client:** `client.links.switch()`
* **Permission:** The caller's own user session, plus a recently authenticated user-session credential for the
target account; both accounts must be the two sides of the link.
* **Audited as:** `identity:switch`, in the target account's tenant.
* **Errors:** `RECENT_AUTH_REQUIRED` when the target credential is not recently authenticated; `INVALID_LINK` when
the link is missing or revoked, does not join these two accounts, or either side is a root administrator or not
a user session.
The new session carries the target credential's MFA state and authentication time, so the target tenant's rules
still apply. Over HTTP the response sets the browser's session cookie, which moves the browser to the target
account; the caller's original session is not ended.
```ts
const { token, session } = await iam.api.links.switch(credential, {
linkId,
targetCredential: { token: freshTargetToken },
});
```
```ts title="Signature"
iam.api.links.switch(
credential: CredentialInput,
input: { linkId: string; targetCredential: CredentialInput },
): Promise
```
# oidcProviders (/docs/reference/api/oidc-providers)
> OIDC providers are the external token issuers a tenant trusts for web-identity federation: GitHub Actions, GitLab, a Kubernetes cluster, or a cloud workload id…
OIDC providers are the external token issuers a tenant trusts for web-identity federation: GitHub Actions, GitLab,
a Kubernetes cluster, or a cloud workload identity service. Registering a provider lets
[web-identity trusts](/docs/reference/api/trust#create) admit its tokens, so a CI job or workload can obtain a role
session through [`sts.assumeRoleWithWebIdentity`](/docs/reference/api/sts#assumerolewithwebidentity) without storing
any IAM secret.
## How providers are managed [#how-providers-are-managed]
Providers are tenant-managed. Creating or changing one needs recent authentication and the deployment switch
`sts.webIdentity.enabled` (see [web-identity federation](/docs/operations/deployment/configuration#web-identity-federation));
reading, deleting, disabling, and revoking sessions keep working when the switch is off, so you can always shut
federation down. Each provider records the grant authority of the administrator who created it. That authority
bounds every session admitted through the provider, and only its holder (or root) may edit or delete the provider,
so an administrator who can change a provider's keys can never mint sessions beyond their own reach.
A provider names its exact `issuer` (https, no query or fragment; never IAM's own issuer, and on
`sts.webIdentity.allowedIssuers` when the deployment pins issuers), the `audiences` its tokens must carry, and where
its keys come from: static public `jwks`, a `jwksUri`, or neither, in which case IAM uses OpenID Connect discovery on
the issuer. Nothing is fetched when a provider is created. Fetches refuse private addresses, redirects, oversized and
non-JSON responses, and keys are cached per provider for `sts.webIdentity.jwksCacheSeconds`.
`replayProtection` is `'single-use'` by default: each token is redeemed at most once at this provider. Set it to
`'off'` for tokens that SDKs reuse until they rotate, such as Kubernetes projected service account tokens.
| Method | What it does | Access |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------- |
| [`create`](#create) | Registers an OIDC provider whose tokens the tenant's web-identity trusts can admit. | Credential |
| [`delete`](#delete) | Deletes a provider that no live trust uses any more. | Credential |
| [`get`](#get) | Returns one provider. | Credential |
| [`list`](#list) | Lists the tenant's OIDC providers, oldest first. | Credential |
| [`revokeSessions`](#revokesessions) | Ends the web-identity sessions issued through a provider before a point in time, across every trust that uses it. | Credential |
| [`update`](#update) | Changes a provider's name, audiences, keys, algorithms, token limits, replay protection, or enabled state. | Credential |
## create [#create]
Registers an OIDC provider whose tokens the tenant's web-identity trusts can admit.
**HTTP:** `POST /api/iam/oidcProviders/create` (requires a credential) · **Browser client:** `client.oidcProviders.create()`
* **Permission:** `iam:oidc-providers:create` on the tenant, with recent authentication and an active grant
authority.
* **Audited as:** `iam:oidc-providers:create`.
* **Errors:** `FEATURE_DISABLED` when web identity is not enabled; `CONFLICT` when the tenant already has a provider
for this issuer; `INVALID_INPUT` for an issuer that is not https, is IAM's own, or is not on
`sts.webIdentity.allowedIssuers`, a `jwksUri` that is not https on port 443 or points at a private address, private
or weak static keys, both `jwks` and `jwksUri`, or an algorithm outside RS256, RS384, RS512, PS256, PS384, PS512,
ES256, ES384, and EdDSA; `RECENT_AUTH_REQUIRED`; `GRANT_AUTHORITY_REQUIRED`.
Defaults: algorithms `RS256` and `ES256`, tokens at most 3600 seconds old and long (`maxTokenLifetimeSeconds`), 30
seconds of clock tolerance, single-use replay protection, and enabled.
```ts
const github = await iam.api.oidcProviders.create(credential, {
tenantId,
name: 'GitHub Actions',
issuer: 'https://token.actions.githubusercontent.com',
audiences: ['https://iam.example.com'],
});
```
```ts title="Signature"
iam.api.oidcProviders.create(
credential: CredentialInput,
input: OidcProviderCreateInput,
): Promise
```
## delete [#delete]
Deletes a provider that no live trust uses any more.
**HTTP:** `POST /api/iam/oidcProviders/delete` (requires a credential) · **Browser client:** `client.oidcProviders.delete()`
* **Permission:** `iam:oidc-providers:delete` on the provider, with recent authentication, and the grant authority it
was created under (or root).
* **Audited as:** `iam:oidc-providers:delete`.
* **Errors:** `CONFLICT` (409) while an unrevoked trust still names the provider; `ACCESS_DENIED` when another
administrator's authority created it; `NOT_FOUND`; `RECENT_AUTH_REQUIRED`.
Revoke the trusts that use it first; revoking a trust already ends its sessions.
```ts title="Signature"
iam.api.oidcProviders.delete(
credential: CredentialInput,
input: { tenantId: string; providerId: string },
): Promise<{ deleted: true }>
```
## get [#get]
Returns one provider.
**HTTP:** `POST /api/iam/oidcProviders/get` (requires a credential) · **Browser client:** `client.oidcProviders.get()`
* **Permission:** `iam:oidc-providers:read` on the provider.
* **Audited as:** `iam:oidc-providers:read`.
* **Errors:** `NOT_FOUND` when the provider is not in this tenant.
```ts title="Signature"
iam.api.oidcProviders.get(
credential: CredentialInput,
input: { tenantId: string; providerId: string },
): Promise
```
## list [#list]
Lists the tenant's OIDC providers, oldest first.
**HTTP:** `POST /api/iam/oidcProviders/list` (requires a credential) · **Browser client:** `client.oidcProviders.list()`
* **Permission:** `iam:oidc-providers:read` on the tenant.
* **Audited as:** `iam:oidc-providers:read`.
```ts title="Signature"
iam.api.oidcProviders.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## revokeSessions [#revokesessions]
Ends the web-identity sessions issued through a provider before a point in time, across every trust that uses it.
**HTTP:** `POST /api/iam/oidcProviders/revokeSessions` (requires a credential) · **Browser client:** `client.oidcProviders.revokeSessions()`
* **Permission:** `iam:roles:revoke-sessions` on the provider, with recent authentication.
* **Audited as:** `iam:roles:revoke-sessions` and `role:sessions-revoked` (with the watermark and the number of
sessions deleted).
* **Errors:** `INVALID_INPUT` when `before` is not a whole number of milliseconds, is negative, or lies in the future;
`NOT_FOUND`; `RECENT_AUTH_REQUIRED`.
`before` defaults to now, which ends every session issued so far. The provider's `sessionsRevokedBefore` watermark
only moves forward, so a session issued earlier is refused at its next use even if it is created concurrently, and
the matching rows are deleted at once. Use it to end sessions without changing the provider, for example after a
workload's token leaked. After a provider's signing key leaks, removing the key with [`update`](#update) is enough:
the update itself ends every session issued so far, so a separate revoke is not needed. Session JWTs checked offline
by other services stay valid until they expire.
```ts
const { revoked } = await iam.api.oidcProviders.revokeSessions(credential, { tenantId, providerId });
```
```ts title="Signature"
iam.api.oidcProviders.revokeSessions(
credential: CredentialInput,
input: OidcProviderRevokeSessionsInput,
): Promise<{ providerId: string; sessionsRevokedBefore: number; revoked: number }>
```
## update [#update]
Changes a provider's name, audiences, keys, algorithms, token limits, replay protection, or enabled state.
**HTTP:** `POST /api/iam/oidcProviders/update` (requires a credential) · **Browser client:** `client.oidcProviders.update()`
* **Permission:** `iam:oidc-providers:update` on the provider, with recent authentication, and the grant authority it
was created under (or root).
* **Audited as:** `iam:oidc-providers:update`.
* **Errors:** `FEATURE_DISABLED` when web identity is off, unless the update only sets `enabled: false`;
`INVALID_INPUT` when the update changes nothing, tries to change the issuer, or gives invalid values (as for
`create`); `ACCESS_DENIED` when another administrator's authority created it; `NOT_FOUND`; `RECENT_AUTH_REQUIRED`.
Only the fields you pass change; `jwksUri: null` or `jwks: null` removes that key source. Cached keys are dropped, so
the next token is checked against the new settings.
Some changes end every session issued through the provider so far, across all of its trusts, because those sessions
were admitted under the old rules: changing `jwks` or `jwksUri` (adding a key for a rotation counts), `algorithms`,
`audiences`, `maxTokenLifetimeSeconds` or `clockToleranceSeconds`, and disabling the provider. Such an update moves
the provider's `sessionsRevokedBefore` watermark to now (the response carries it) and deletes the matching session
rows, as [`revokeSessions`](#revokesessions) would; workloads simply exchange a fresh token. Changing `name` or
`replayProtection`, or enabling the provider, keeps live sessions. Plan key rotations for a quiet moment, or publish
keys through `jwksUri` or discovery so rotations need no update at all.
`enabled: false` is the kill switch: a disabled provider admits no exchanges, and the sessions issued through it end
for good, so enabling it again does not bring them back.
```ts title="Signature"
iam.api.oidcProviders.update(
credential: CredentialInput,
input: OidcProviderUpdateInput,
): Promise
```
# onboarding (/docs/reference/api/onboarding)
> Onboarding flows are checklists for newcomers, customized at every level of the tenant tree.
Onboarding flows are checklists for newcomers, customized at every level of the tenant tree. `member` flows walk
people who join a tenant through their first steps (forms, acknowledgements, tasks, accepting terms of use, verifying
their email address, enrolling MFA or a passkey); `tenant` flows are setup checklists for the organizations or projects
below the defining tenant, with checks that follow the tenant's real state (a verified domain, enough owners and
members, an MFA policy, SSO, directory sync). Flows defined at the platform root reach every tenant below;
organizations and projects add their own, switch off unlocked inherited flows, and override the welcome screen. The
repository guide is `docs/onboarding.md`.
## Levels and inheritance [#levels-and-inheritance]
A flow's `appliesTo` decides whom it reaches: `tenant` (the defining tenant's own people), `descendants` (the people
of the tenants below it) or `subtree` (both); `tenantTypes` narrows descendants to some tenant types. Tenant flows
always reach descendants. A person sees the flows of every level at once, platform first. A tenant switches off an
inherited member flow for itself and everything below it with `setSettings({ disabledFlowIds })`, unless the flow is
`locked`. Welcome values (`welcomeTitle`, `welcomeMessage`, `supportEmail`, `supportUrl`) come from the nearest level
that set them.
Flows ask only people (or tenants) created after they took effect, unless `includeExisting` is set; member flows can
also target people with a `rule` in the access-package rule language. Policies see `principal.onboarding` (completed
flow names) and `principal.pendingOnboarding` (required flows still open), read only when a condition names them:
```json
{
"effect": "deny",
"actions": ["documents:*"],
"resources": ["*"],
"conditions": { "NumericGreaterThan": { "principal.pendingOnboarding": 0 } }
}
```
| Method | What it does | Access |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`createFlow`](#createflow) | Creates a flow at this tenant's level. | Credential |
| [`deleteFlow`](#deleteflow) | Deletes a flow with every progress record of it, in every tenant it reached, and removes it from tenants' `disabledFlowIds`. | Credential |
| [`effective`](#effective) | Everything onboarding looks like from this tenant, for administration pages. | Credential |
| [`getFlow`](#getflow) | Returns one flow this tenant defines. | Credential |
| [`listFlows`](#listflows) | Lists the flows this tenant defines, oldest first, optionally for one `audience`. | Credential |
| [`memberProgress`](#memberprogress) | One person's member flows in this tenant, with the state of every step and their answers, and `pending` (required flows still open). | Credential |
| [`mine`](#mine) | The caller's own onboarding: the welcome screen and their member flows, with step states and their own answers. | Credential |
| [`progress`](#progress) | Progress through one flow as this tenant sees it. | Credential |
| [`resetProgress`](#resetprogress) | Clears progress through a flow, or one step of it (`stepId`), so people or tenants go through it again. | Credential |
| [`setSettings`](#setsettings) | Replaces this tenant's onboarding settings: `welcomeTitle`, `welcomeMessage`, `supportEmail`, `supportUrl` (empty or `null` falls back to the level above), and `disabledFlowIds`, the inherited member flows switched off for this tenant and every tenant below it. | Credential |
| [`setup`](#setup) | This tenant's own setup checklists (the tenant flows defined above it), with the state of every step and the answers. | Credential |
| [`submitSetupStep`](#submitsetupstep) | Completes a `form`, `acknowledge`, or `task` step of this tenant's setup. | Credential |
| [`submitStep`](#submitstep) | Completes one step of the caller's own member flow: `answers` for a form, `acknowledged: true` for an acknowledgement, nothing for a task (an `admin`-verified task is submitted for review). | Credential |
| [`updateFlow`](#updateflow) | Changes any field of a flow but its audience. Changing the steps bumps `version`: finished steps stay finished (progress is kept per step ID) and a new required step reopens the flow for everyone it applies to. | Credential |
| [`verifyStep`](#verifystep) | Approves (the default) or sends back (`approve: false`, with an optional `note`) an administrator-verified task. | Credential |
## createFlow [#createflow]
Creates a flow at this tenant's level.
**HTTP:** `POST /api/iam/onboarding/createFlow` (requires a credential) · **Browser client:** `client.onboarding.createFlow()`
* **Permission:** `iam:onboarding:manage` on the tenant. Form fields that fill identity attributes (`attribute`) also
need `iam:identities:update`; `completionGroupIds` need `iam:groups:update` on each group and the use of the grant
authorities behind the group's bindings. Both are refused from role sessions, session tokens, and impersonation.
* **Audited as:** `iam:onboarding:manage`.
* **Errors:** `CONFLICT` (409) for a name the tenant already uses; `LIMIT_EXCEEDED` (409) past 50 flows;
`INVALID_INPUT` for an unknown field, a step kind the audience does not allow, duplicate step IDs, an undeclared or
mistyped attribute, a textarea field mapped to an attribute, attribute mappings on a flow that reaches only tenants
below, `tenantTypes` that cannot exist below the tenant, `descendants` on a tenant type without
children, completion groups on a flow that reaches only descendants, or a rule that tests `identity.groups` on such a
flow; `NOT_FOUND` for an unknown completion group; `ACCESS_DENIED` when the attribute or group permissions are
missing.
Steps (1-25) have a stable `id` (lowercase letters, digits, dashes) and a `kind`: `form` (1-20 `fields`), `acknowledge`
(`content`), `task` (`url`, `verification: 'self' | 'admin'`), and for member flows `agreement` (by name),
`verify-email`, `mfa`, `passkey`; for tenant flows `check` (`verified-domain`, `members`, `owners`, `mfa-policy`,
`agreement`, `slug`, `sso`, `directory-sync`, `member-onboarding`, with `minimum` for members and owners). `appliesTo`
defaults to `descendants` at the root and `tenant` elsewhere.
```ts
await iam.api.onboarding.createFlow(rootCredential, {
tenantId: rootTenantId,
name: 'Platform essentials',
audience: 'member',
locked: true,
steps: [
{ id: 'conduct', kind: 'acknowledge', title: 'Acceptable use', content: 'Use the service lawfully.' },
{ id: 'mfa', kind: 'mfa', title: 'Set up two-step verification' },
],
});
```
```ts title="Signature"
iam.api.onboarding.createFlow(
credential: CredentialInput,
input: OnboardingFlowInput & { tenantId: string },
): Promise
```
## deleteFlow [#deleteflow]
Deletes a flow with every progress record of it, in every tenant it reached, and removes it from tenants' `disabledFlowIds`.
**HTTP:** `POST /api/iam/onboarding/deleteFlow` (requires a credential) · **Browser client:** `client.onboarding.deleteFlow()`
* **Permission:** `iam:onboarding:manage` on the flow.
* **Audited as:** `iam:onboarding:manage`.
* **Errors:** `NOT_FOUND` when this tenant does not define the flow.
Returns `{ deleted: true, progressRemoved }`.
```ts title="Signature"
iam.api.onboarding.deleteFlow(
credential: CredentialInput,
input: { tenantId: string; flowId: string },
): Promise<{ deleted: boolean; progressRemoved: number }>
```
## effective [#effective]
Everything onboarding looks like from this tenant, for administration pages.
**HTTP:** `POST /api/iam/onboarding/effective` (requires a credential) · **Browser client:** `client.onboarding.effective()`
* **Permission:** `iam:onboarding:read` on the tenant.
* **Audited as:** `iam:onboarding:read`.
Returns the `levels` (root first), `memberFlows` reaching the tenant's people (own and inherited, each with `source`,
`inherited`, `disabledBy`, and `canDisable`), the `setupFlows` the tenant's administrators complete, its `ownFlows`,
the `descendantTypes` a flow may target, the declared `identityAttributes`, and `settings` (`own` and `resolved`, with
the level each value came from). Inherited flows omit the defining tenant's completion groups and author.
```ts title="Signature"
iam.api.onboarding.effective(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## getFlow [#getflow]
Returns one flow this tenant defines.
**HTTP:** `POST /api/iam/onboarding/getFlow` (requires a credential) · **Browser client:** `client.onboarding.getFlow()`
* **Permission:** `iam:onboarding:read` on the flow.
* **Audited as:** `iam:onboarding:read`.
* **Errors:** `NOT_FOUND` when this tenant does not define the flow.
```ts title="Signature"
iam.api.onboarding.getFlow(
credential: CredentialInput,
input: { tenantId: string; flowId: string },
): Promise
```
## listFlows [#listflows]
Lists the flows this tenant defines, oldest first, optionally for one `audience`.
**HTTP:** `POST /api/iam/onboarding/listFlows` (requires a credential) · **Browser client:** `client.onboarding.listFlows()`
* **Permission:** `iam:onboarding:read` on the tenant.
* **Audited as:** `iam:onboarding:read`.
```ts title="Signature"
iam.api.onboarding.listFlows(
credential: CredentialInput,
input: { tenantId: string; audience?: OnboardingAudience },
): Promise
```
## memberProgress [#memberprogress]
One person's member flows in this tenant, with the state of every step and their answers, and `pending` (required flows still open).
**HTTP:** `POST /api/iam/onboarding/memberProgress` (requires a credential) · **Browser client:** `client.onboarding.memberProgress()`
* **Permission:** `iam:onboarding:read` on the identity.
* **Audited as:** `iam:onboarding:read`.
* **Errors:** `NOT_FOUND` for an identity outside the tenant or deleted.
```ts title="Signature"
iam.api.onboarding.memberProgress(
credential: CredentialInput,
input: { tenantId: string; identityId: string },
): Promise<{
identity: { id: string; name: string; email?: string };
flows: OnboardingFlowStatus[];
pending: number;
}>
```
## mine [#mine]
The caller's own onboarding: the welcome screen and their member flows, with step states and their own answers.
**HTTP:** `POST /api/iam/onboarding/mine` (requires a credential) · **Browser client:** `client.onboarding.mine()`
* **Permission:** None beyond an ordinary session (or API key) of the tenant.
* **Audited as:** `onboarding:complete` for each flow seen complete for the first time in its current version.
* **Errors:** `ACCESS_DENIED` from a role session, a session token, or another tenant's session.
Returns `{ tenant, welcome, flows, pending, complete }`. Each flow carries `source` (the level that defines it),
`required`, `steps` (`state`: `pending`, `complete`, `submitted`, `rejected` with the reviewer's `note`, or
`unavailable`), `done`, and `total`. Completion groups of newly finished flows are applied after the read, in their own
transaction; a refusal (such as a separation-of-duties rule) is kept as the progress record's `completionError` and
retried on the next read. Impersonating administrators see the checklist, but nothing is recorded. Service accounts
have no flows.
```ts title="Signature"
iam.api.onboarding.mine(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## progress [#progress]
Progress through one flow as this tenant sees it.
**HTTP:** `POST /api/iam/onboarding/progress` (requires a credential) · **Browser client:** `client.onboarding.progress()`
* **Permission:** `iam:onboarding:read` on the flow.
* **Audited as:** `iam:onboarding:read`.
* **Errors:** `NOT_FOUND` when the flow neither belongs to nor reaches this tenant (setup flows report only at the
tenant that defines them).
For member flows, `members` lists this tenant's people the flow applies to, with `done`, `total`, `complete`,
`awaiting` (tasks waiting for review), and `answers` by step. A flow defined here that reaches tenants below adds
`descendants`: per tenant, how many people it applies to and how many finished, never names. For setup flows,
`tenants` lists every descendant tenant the flow applies to, with its answers. `summary` counts subjects and completions;
`truncated` is set past 1000 descendant tenants.
```ts title="Signature"
iam.api.onboarding.progress(
credential: CredentialInput,
input: { tenantId: string; flowId: string },
): Promise
```
## resetProgress [#resetprogress]
Clears progress through a flow, or one step of it (`stepId`), so people or tenants go through it again.
**HTTP:** `POST /api/iam/onboarding/resetProgress` (requires a credential) · **Browser client:** `client.onboarding.resetProgress()`
* **Permission:** `iam:onboarding:manage` on the flow. The tenant that defines the flow may reset anyone it reaches
(everyone when `subjectId` is omitted); a tenant a member flow reaches may reset its own people.
* **Audited as:** `onboarding:reset`, with `reset` (the number of progress records) and the `subjectId` / `stepId`.
* **Errors:** `NOT_FOUND` for a flow that does not reach the tenant or an unknown step.
```ts title="Signature"
iam.api.onboarding.resetProgress(
credential: CredentialInput,
input: { tenantId: string; flowId: string; subjectId?: string; stepId?: string },
): Promise<{ reset: number }>
```
## setSettings [#setsettings]
Replaces this tenant's onboarding settings: `welcomeTitle`, `welcomeMessage`, `supportEmail`, `supportUrl` (empty or `null` falls back to the level above), and `disabledFlowIds`, the inherited member flows switched off for this tenant and every tenant below it.
**HTTP:** `POST /api/iam/onboarding/setSettings` (requires a credential) · **Browser client:** `client.onboarding.setSettings()`
* **Permission:** `iam:onboarding:manage` on the tenant.
* **Audited as:** `iam:onboarding:manage`.
* **Errors:** `INVALID_INPUT` for a flow that is not an inherited member flow of the tenant, a locked flow, a malformed
email or URL, or an unknown field.
A switch kept from before whose flow no longer reaches the tenant (deleted, retargeted, or locked since) is dropped
quietly, so re-saving never fails on someone else's change.
```ts title="Signature"
iam.api.onboarding.setSettings(
credential: CredentialInput,
input: OnboardingSettingsInput,
): Promise
```
## setup [#setup]
This tenant's own setup checklists (the tenant flows defined above it), with the state of every step and the answers.
**HTTP:** `POST /api/iam/onboarding/setup` (requires a credential) · **Browser client:** `client.onboarding.setup()`
* **Permission:** `iam:onboarding:read` on the tenant.
* **Audited as:** `iam:onboarding:read`, and `onboarding:complete` for a checklist seen complete for the first time.
Returns the same shape as `mine`. Check steps carry a `detail` such as `1 of 2 owners`.
```ts title="Signature"
iam.api.onboarding.setup(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## submitSetupStep [#submitsetupstep]
Completes a `form`, `acknowledge`, or `task` step of this tenant's setup.
**HTTP:** `POST /api/iam/onboarding/submitSetupStep` (requires a credential) · **Browser client:** `client.onboarding.submitSetupStep()`
* **Permission:** `iam:onboarding:manage` on the flow.
* **Audited as:** `onboarding:step`, and `onboarding:complete` when the checklist is done.
* **Errors:** `INVALID_INPUT` for a check step (they follow the tenant's state), missing or invalid answers, or an
acknowledgement without `acknowledged: true`; `IMPERSONATION_RESTRICTED` while impersonating; `NOT_FOUND` for a flow
that does not apply to the tenant or an unknown step.
```ts title="Signature"
iam.api.onboarding.submitSetupStep(
credential: CredentialInput,
input: {
tenantId: string;
flowId: string;
stepId: string;
answers?: Record;
acknowledged?: boolean;
},
): Promise<{ flow: OnboardingFlowStatus }>
```
## submitStep [#submitstep]
Completes one step of the caller's own member flow: `answers` for a form, `acknowledged: true` for an acknowledgement, nothing for a task (an `admin`-verified task is submitted for review).
**HTTP:** `POST /api/iam/onboarding/submitStep` (requires a credential) · **Browser client:** `client.onboarding.submitStep()`
* **Permission:** None beyond an ordinary session of the tenant.
* **Audited as:** `onboarding:step` (with the attributes the answers filled), and `onboarding:complete` when the flow is
done.
* **Errors:** `INVALID_INPUT` for a step that completes on its own, a missing required answer, an answer of the wrong
type or outside a select's choices, or an unknown answer key; `IMPERSONATION_RESTRICTED` while impersonating;
`NOT_FOUND` for a flow that does not apply to the caller or an unknown step.
Answers mapped to identity attributes fill only empty attributes, and only when the caller's own tenant defines the
flow (an inherited flow records answers but never writes the tenant's identities); values an administrator or
directory sync set are kept. Returns `{ flow, attributesFilled }`.
```ts
await iam.api.onboarding.submitStep(credential, {
tenantId,
flowId,
stepId: 'profile',
answers: { department: 'Engineering' },
});
```
```ts title="Signature"
iam.api.onboarding.submitStep(
credential: CredentialInput,
input: {
tenantId: string;
flowId: string;
stepId: string;
answers?: Record;
acknowledged?: boolean;
},
): Promise<{ flow: OnboardingFlowStatus; attributesFilled: string[] }>
```
## updateFlow [#updateflow]
Changes any field of a flow but its audience. Changing the steps bumps `version`: finished steps stay finished (progress is kept per step ID) and a new required step reopens the flow for everyone it applies to.
**HTTP:** `POST /api/iam/onboarding/updateFlow` (requires a credential) · **Browser client:** `client.onboarding.updateFlow()`
* **Permission:** `iam:onboarding:manage` on the flow, plus the attribute and group permissions of `createFlow` for newly
mapped attributes and newly added completion groups.
* **Audited as:** `iam:onboarding:manage`.
* **Errors:** as `createFlow`, and `NOT_FOUND` when this tenant does not define the flow.
`null` clears `description`, `tenantTypes`, `rule`, and `completionGroupIds`. Pausing (`enabled: false`) asks nobody;
the flow's `effectiveFrom` is set the first time it is enabled.
```ts title="Signature"
iam.api.onboarding.updateFlow(
credential: CredentialInput,
input: OnboardingFlowUpdate,
): Promise
```
## verifyStep [#verifystep]
Approves (the default) or sends back (`approve: false`, with an optional `note`) an administrator-verified task.
**HTTP:** `POST /api/iam/onboarding/verifyStep` (requires a credential) · **Browser client:** `client.onboarding.verifyStep()`
* **Permission:** `iam:onboarding:manage`. For member flows the caller administers the person's tenant (`tenantId`)
and `subjectId` is the person; for setup flows the caller administers the tenant that defines the flow and
`subjectId` is the tenant being set up.
* **Audited as:** `onboarding:verify` or `onboarding:reject`.
* **Errors:** `INVALID_INPUT` for a step that is not an administrator-verified task, or when people verify their own
onboarding; `IMPERSONATION_RESTRICTED` while impersonating; `NOT_FOUND` when the flow does not apply to the subject.
Approving the last open step records the flow as complete; completion groups are applied the next time the person
reads their onboarding.
```ts title="Signature"
iam.api.onboarding.verifyStep(
credential: CredentialInput,
input: {
tenantId: string;
flowId: string;
subjectId: string;
stepId: string;
approve?: boolean;
note?: string;
},
): Promise<{ flow: OnboardingFlowStatus }>
```
# packages (/docs/reference/api/packages)
> Access packages bundle roles and group memberships that are granted, requested, and removed together.
Access packages bundle roles and group memberships that are granted, requested, and removed together. An
onboarding kit, a project profile, or a vendor's access becomes one named package instead of a checklist of
separate grants, and removing it takes away exactly what it gave. See the
[access packages guide](/docs/guides/privileged-access/access-packages) for a walkthrough.
## How packages are granted [#how-packages-are-granted]
A package can reach a person in three ways, and one package can use all of them:
* **Assignment.** An administrator calls `assign`. The caller needs `iam:packages:assign` on the package plus
everything the direct calls need: `iam:bindings:create` on each role and `iam:groups:update` on each group. The
records are created under the caller's own [grant authority](/docs/guides/authorization/roles#grant-authorities),
so a package never lets anyone grant more than they could grant by hand.
* **Self-service request.** When the package is `requestable`, a member holding `iam:packages:request` on it asks
for it with `request`, and an approver decides with `approveRequest` or `denyRequest`. Who may approve depends on
the package: with neither `approverGroupId` nor `managerApproval`, anyone holding `iam:packages:approve` on the
package; otherwise only live members of the approver group, the requester's manager (with `managerApproval`), or
a root administrator. Nobody decides on their own request, and never from an
[impersonation](/docs/guides/authentication/impersonation) session. Approval assigns the package under the
approver's authority, so the approver needs the same rights as `assign`. A request waits for the tenant's
`approvalLifetimeMs` (one day by default, set with
[`tenants.setAccessPolicy`](/docs/reference/api/tenants#setaccesspolicy)), never beyond the end it asks for.
* **Rule (birthright).** A package with an `autoAssign` rule is given to every active identity that matches the
rule and taken back from automatic holders that stop matching. A reconciler applies the rule under the grant
authority of the rule's owner (whoever last saved the rule or changed the package's contents). It runs after
identity changes and rule saves, on demand with `reconcile`, and as the scheduled job
[`iam.reconcilePackages()`](/docs/reference/api#reconcilepackages), which you must schedule. See
[automatic assignment](/docs/guides/privileged-access/automatic-assignment) for the rule language, grace
periods, and the safety brake.
`maxDurationMs` makes an end date mandatory for assignments and requests and caps it; `requireJustification` makes
the justification mandatory. A rule package cannot have `maxDurationMs` (use the rule's `graceMs` for a delayed
end), and its automatic assignments satisfy `requireJustification` with "Automatic: matches the package rule".
## What an assignment owns [#what-an-assignment-owns]
An assignment turns every packaged role into an identity binding of its own and every packaged group into a
membership, all ending at the assignment's `expiresAt`. Each record is tagged with the assignment. Because the
bindings are the assignment's own, a package never depends on, replaces, or removes a binding someone granted by
hand. A group has one membership record per person, so memberships are shared:
* A membership the person already holds for at least as long is left alone and reported in `skipped`.
* A shorter membership is extended and becomes the assignment's.
* When two of a person's packages include the same group, the membership belongs to whichever needs it longest and
passes to the other when that one is revoked or shortened.
Revoking removes exactly the records the assignment still owns, together with any just-in-time activations they
carried, then the assignment itself. Editing one of its records by hand (`bindings.update`, `groups.updateMember`,
re-adding a lapsed member) takes that record over, so revoking the package no longer removes it. An assignment ends
by itself at `expiresAt`: it stops granting at once, and the purge worker (`iam.purgeDeleted()`) deletes it with
its records later. An assignment whose bindings no longer grant, for example because the assigner was offboarded
and their authority revoked, is reported as `broken`; assign or request the package again to replace it.
Offboarding an identity revokes its assignments and cancels its pending requests; deleting it removes both. A role
or group cannot be deleted while a package includes it.
| Method | What it does | Access |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------- |
| [`approveRequest`](#approverequest) | Grants a pending package request by assigning the package to the requester under your own authority. | Credential |
| [`assign`](#assign) | Grants a package to a person: one binding per role and one membership per group, all ending together. | Credential |
| [`cancelRequest`](#cancelrequest) | Withdraws one of your own pending package requests. | Credential |
| [`create`](#create) | Defines a package of roles and groups, optionally requestable or assigned automatically by a rule. | Credential |
| [`delete`](#delete) | Deletes a package nobody holds, together with its request history. | Credential |
| [`denyRequest`](#denyrequest) | Refuses a pending package request, with an optional note to the requester. | Credential |
| [`extend`](#extend) | Moves the end of a person's manual assignment, for the assignment and every record it created at once. | Credential |
| [`get`](#get) | Returns one package with its live holder counts and, for a rule package, the rule's state. | Credential |
| [`list`](#list) | Lists the tenant's packages by name, each with its live holder counts. | Credential |
| [`listApprovals`](#listapprovals) | Lists the pending package requests you may decide on. | Credential |
| [`listAssignments`](#listassignments) | Lists the holders of a package, or the packages of a person, newest first. | Credential |
| [`listMine`](#listmine) | Returns your self-service view: the requestable packages with your status on each, your assignments, and your recent requests. | Credential |
| [`listRequests`](#listrequests) | Lists package requests for a package or a person, newest first, optionally by status. | Credential |
| [`previewAutoAssign`](#previewautoassign) | Shows what a package rule matches and, for a package, what a reconcile would change, without writing anything. | Credential |
| [`reconcile`](#reconcile) | Runs the package-rule reconciler now for the tenant or one package, optionally confirming held-back changes. | Credential |
| [`request`](#request) | Asks for a requestable package for yourself; approvers are notified and one of them decides. | Credential |
| [`revoke`](#revoke) | Removes a package from a person, taking away exactly the bindings and memberships the assignment still owns. | Credential |
| [`update`](#update) | Changes a package's name, description, contents, request settings, or rule. | Credential |
## approveRequest [#approverequest]
Grants a pending package request by assigning the package to the requester under your own authority.
**HTTP:** `POST /api/iam/packages/approveRequest` (requires a credential) · **Browser client:** `client.packages.approveRequest()`
* **Permission:** `iam:packages:approve` on the package; when the package names approvers, membership of the
approver group or being the requester's manager (or root); plus the rights `assign` needs for every role and group.
* **Audited as:** `iam:packages:approve`, plus `package:request-approved` with the assignment's counts, skips, and end.
* **Errors:** `INVALID_TRANSITION` when the request is no longer pending or the package is no longer requestable;
`NOT_FOUND` when the request is not in this tenant; `INVALID_INPUT` for your own request or an end the package
does not allow; `ACCESS_DENIED`
when you are not a designated approver or cannot grant one of the packaged roles or groups;
`IMPERSONATION_RESTRICTED` from an impersonation session; `GRANT_AUTHORITY_REQUIRED` without a grant authority;
`SOD_CONFLICT` when the assignment would create a [separation-of-duties](/docs/guides/authorization/separation-of-duties)
conflict; `INVARIANT_VIOLATION` when it would break an enforced invariant.
The assignment ends at the `expiresAt` you pass or, without it, at the end the requester asked for, validated
against the package's `maxDurationMs`. The optional `note` is stored on the request. When the deployment sends
email, the requester receives a `package-decided` message.
```ts
await iam.api.packages.approveRequest(credential, {
tenantId,
requestId,
expiresAt: Date.now() + 14 * 24 * 60 * 60 * 1000, // shorter than the 30 days they asked for
note: 'Two weeks covers the migration.',
});
```
```ts title="Signature"
iam.api.packages.approveRequest(
credential: CredentialInput,
input: { tenantId: string; requestId: string; expiresAt?: number; note?: string },
): Promise
```
## assign [#assign]
Grants a package to a person: one binding per role and one membership per group, all ending together.
**HTTP:** `POST /api/iam/packages/assign` (requires a credential) · **Browser client:** `client.packages.assign()`
* **Permission:** `iam:packages:assign` on the package, `iam:bindings:create` on each packaged role,
`iam:groups:update` on each packaged group, and authority over the role bindings of each group the person joins.
* **Audited as:** `iam:packages:assign`, plus `package:assign` with the created counts, `skipped`, end, and
justification.
* **Errors:** `CONFLICT` when the person already holds a manual assignment of the package that is not broken;
`NOT_FOUND`
when the package or person is not in this tenant or the person is deleted; `INVALID_INPUT` when an end is
required and missing, exceeds `maxDurationMs`, or a required justification is missing; `ACCESS_DENIED` without
the rights for one of the parts; `GRANT_AUTHORITY_REQUIRED` without a grant authority; `SOD_CONFLICT` and
`INVARIANT_VIOLATION` when the grant would create a conflict or break an enforced invariant.
Everything happens in one transaction, so either the whole package is granted or nothing is. The result carries the
assignment, `created` (bindings and memberships), `skipped` (memberships the person already held for as long), and
`replacedAutomatic`. Assigning a package the person holds through its rule takes the automatic assignment over: it
becomes a manual one under your authority. Assigning also marks the person's pending request for the package as
approved.
```ts
const result = await iam.api.packages.assign(credential, {
tenantId,
packageId,
identityId,
expiresAt: Date.now() + 90 * 24 * 60 * 60 * 1000,
justification: 'Joins the Atlas project for Q4',
});
// result.created: { bindings: 2, memberships: 1 }, result.skipped: [...]
```
```ts title="Signature"
iam.api.packages.assign(
credential: CredentialInput,
input: AssignmentInput,
): Promise<{
created: { bindings: number; memberships: number };
skipped: string[];
replacedAutomatic: boolean;
id: string;
tenantId: string;
packageId: string;
identityId: string;
assignedBy: string;
assignedAt: number;
expiresAt?: number;
justification?: string;
bindingIds: string[];
membershipIds: string[];
packageName: string;
identityName: string;
identityEmail?: string;
expired: boolean;
broken: boolean;
automatic: boolean;
}>
```
## cancelRequest [#cancelrequest]
Withdraws one of your own pending package requests.
**HTTP:** `POST /api/iam/packages/cancelRequest` (requires a credential) · **Browser client:** `client.packages.cancelRequest()`
* **Permission:** `iam:packages:request` on the package, and you must be the requester.
* **Audited as:** `iam:packages:request`, plus `package:request-cancelled`.
* **Errors:** `ACCESS_DENIED` when the request is someone else's; `INVALID_TRANSITION` when it is no longer pending;
`NOT_FOUND` when the request is not in this tenant.
```ts title="Signature"
iam.api.packages.cancelRequest(
credential: CredentialInput,
input: { tenantId: string; requestId: string },
): Promise
```
## create [#create]
Defines a package of roles and groups, optionally requestable or assigned automatically by a rule.
**HTTP:** `POST /api/iam/packages/create` (requires a credential) · **Browser client:** `client.packages.create()`
* **Permission:** `iam:packages:create` on the tenant. With `autoAssign`, also `iam:packages:assign` on the package
and every right `assign` needs, including authority over the packaged groups' bindings.
* **Audited as:** `iam:packages:create`, plus `package:auto-rule` when a rule is set.
* **Errors:** `CONFLICT` when a package with the same name (ignoring case) exists; `INVALID_INPUT` when it has no
role or group, more than 50 of either, a `maxDurationMs` outside one minute to ten years, or a rule that does not
validate; `PROTECTED_RESOURCE` for a protected (owner) role; `NOT_FOUND` when a role, group, or approver group is
not in this tenant; `INVALID_POLICY` when a rule clause is not a valid condition block. With a rule, also
`IMPERSONATION_RESTRICTED`, `ACCESS_DENIED`, and `GRANT_AUTHORITY_REQUIRED` from the owner checks.
A new package without a rule grants nothing until you assign it or someone requests it. With `autoAssign` you
become the rule's owner, and the rule must be set from an ordinary session or API key, never an assumed role. After
the save commits, a first reconcile of up to 200 changes runs and its result is returned as `reconcile`; if it
fails, the call still succeeds and the scheduled job catches up. The save also pre-approves the rule's planned counts
for a day, so scheduled runs in that window apply the rest without the safety brake holding them back.
```ts
// Everyone in engineering (people, not service accounts) gets the kit automatically.
const pkg = await iam.api.packages.create(credential, {
tenantId,
name: 'Engineering onboarding',
roleIds: [developerRole.id],
groupIds: [engineeringGroup.id],
autoAssign: {
include: [{ StringEquals: { 'principal.kind': 'user', 'principal.department': 'engineering' } }],
graceMs: 7 * 24 * 60 * 60 * 1000,
},
});
// pkg.reconcile?.assigned: how many people received it right away
```
```ts title="Signature"
iam.api.packages.create(
credential: CredentialInput,
input: PackageInput,
): Promise
```
## delete [#delete]
Deletes a package nobody holds, together with its request history.
**HTTP:** `POST /api/iam/packages/delete` (requires a credential) · **Browser client:** `client.packages.delete()`
* **Permission:** `iam:packages:delete` on the package.
* **Audited as:** `iam:packages:delete`.
* **Errors:** `RESOURCE_IN_USE` (409) while any live assignment exists; `NOT_FOUND` when the package is not in this
tenant.
Revoke manual assignments first. For a rule package, set `autoAssign` to `null` with `update` so reconciliation
removes the automatic assignments, then delete. Ended assignments, requests, and rule issues are deleted with the
package.
```ts title="Signature"
iam.api.packages.delete(
credential: CredentialInput,
input: { tenantId: string; packageId: string },
): Promise<{ deleted: true }>
```
## denyRequest [#denyrequest]
Refuses a pending package request, with an optional note to the requester.
**HTTP:** `POST /api/iam/packages/denyRequest` (requires a credential) · **Browser client:** `client.packages.denyRequest()`
* **Permission:** `iam:packages:approve` on the package and, when the package names approvers, membership of the
approver group or being the requester's manager (or root).
* **Audited as:** `iam:packages:approve`, plus `package:request-denied`.
* **Errors:** `INVALID_TRANSITION` when the request is no longer pending; `NOT_FOUND` when the request is not in
this tenant; `INVALID_INPUT` for your own request; `ACCESS_DENIED` when you are not a designated approver;
`IMPERSONATION_RESTRICTED` from an impersonation session.
Denying needs no grant rights, since nothing is granted. When the deployment sends email, the requester receives a
`package-decided` message with your note.
```ts title="Signature"
iam.api.packages.denyRequest(
credential: CredentialInput,
input: { tenantId: string; requestId: string; note?: string },
): Promise
```
## extend [#extend]
Moves the end of a person's manual assignment, for the assignment and every record it created at once.
**HTTP:** `POST /api/iam/packages/extend` (requires a credential) · **Browser client:** `client.packages.extend()`
* **Permission:** `iam:packages:assign` on the package. Lengthening, or `expiresAt: null`, is granting: it also
needs the rights `assign` needs and a grant authority.
* **Audited as:** `iam:packages:assign`, plus `package:extend` with the previous and new end.
* **Errors:** `NOT_FOUND` when the person holds no live assignment of the package; `INVALID_TRANSITION` for an
automatic assignment; `INVALID_INPUT` when `expiresAt` is missing or the new end breaks the package's
`maxDurationMs` (including `null` on a capped package); `ACCESS_DENIED` or `GRANT_AUTHORITY_REQUIRED` when
lengthening without the rights.
Shortening needs no more than revoking does. When you lengthen, the assignment's bindings move to your authority, so
the longer grant is bounded by what you may give. Shortening a shared membership hands it to another of the person's
packages that still needs it longer. Automatic assignments end when the person stops matching the rule; assign the
package manually to take one over and set an end.
```ts
await iam.api.packages.extend(credential, { tenantId, packageId, identityId, expiresAt: Date.parse('2026-12-31') });
```
```ts title="Signature"
iam.api.packages.extend(
credential: CredentialInput,
input: {
tenantId: string;
packageId: string;
identityId: string;
expiresAt: number | null;
},
): Promise
```
## get [#get]
Returns one package with its live holder counts and, for a rule package, the rule's state.
**HTTP:** `POST /api/iam/packages/get` (requires a credential) · **Browser client:** `client.packages.get()`
* **Permission:** `iam:packages:read` on the package.
* **Audited as:** `iam:packages:read`.
* **Errors:** `NOT_FOUND` when the package is not in this tenant.
`assignments` counts live holders and `automaticAssignments` those the rule assigned. A rule package also returns
`autoAssign` with the owner's name, `status` (`active` or `suspended`, with the reason), advice in `warnings`, and
the 20 newest problems in `issues`.
```ts title="Signature"
iam.api.packages.get(
credential: CredentialInput,
input: { tenantId: string; packageId: string },
): Promise
```
## list [#list]
Lists the tenant's packages by name, each with its live holder counts.
**HTTP:** `POST /api/iam/packages/list` (requires a credential) · **Browser client:** `client.packages.list()`
* **Permission:** `iam:packages:read` on the tenant.
* **Audited as:** `iam:packages:read`.
```ts title="Signature"
iam.api.packages.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listApprovals [#listapprovals]
Lists the pending package requests you may decide on.
**HTTP:** `POST /api/iam/packages/listApprovals` (requires a credential) · **Browser client:** `client.packages.listApprovals()`
* **Permission:** `iam:packages:approve` on the tenant.
* **Audited as:** `iam:packages:approve`.
Use it for an approver's inbox. Each request is included only when its package is still requestable, you hold
`iam:packages:approve` on that package, you satisfy the package's approver rules, and the request is not your own.
```ts title="Signature"
iam.api.packages.listApprovals(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listAssignments [#listassignments]
Lists the holders of a package, or the packages of a person, newest first.
**HTTP:** `POST /api/iam/packages/listAssignments` (requires a credential) · **Browser client:** `client.packages.listAssignments()`
* **Permission:** `iam:packages:read` on the package when you pass `packageId`, otherwise on the tenant.
* **Audited as:** `iam:packages:read`.
* **Errors:** `NOT_FOUND` when `packageId` is not in this tenant; `INVALID_INPUT` when `source` is not `automatic`
or `manual`.
Ended assignments are included only with `includeExpired: true`. Each entry names the package and person and
reports `expired`, `broken` (its bindings no longer grant), and `automatic` (assigned by the rule).
```ts title="Signature"
iam.api.packages.listAssignments(
credential: CredentialInput,
input: {
tenantId: string;
packageId?: string;
identityId?: string;
includeExpired?: boolean;
source?: 'automatic' | 'manual';
},
): Promise
```
## listMine [#listmine]
Returns your self-service view: the requestable packages with your status on each, your assignments, and your recent requests.
**HTTP:** `POST /api/iam/packages/listMine` (requires a credential) · **Browser client:** `client.packages.listMine()`
* **Permission:** `iam:packages:request` on the tenant.
* **Audited as:** `iam:packages:request`.
Each requestable package comes with its roles and groups named, the approver group's name, and your live
`assignment` or `pending` request, which is enough to render "held", "awaiting approval", or "request". A package's
rule is never shown here. `requests` holds your 50 most recent requests. Every requestable package is listed, even
one you lack `iam:packages:request` on, so `request` can still refuse it.
```ts title="Signature"
iam.api.packages.listMine(
credential: CredentialInput,
input: { tenantId: string },
): Promise<{
packages: {
assignment: PackageAssignmentSummary | undefined;
pending: PackageRequestSummary | undefined;
approverGroupName?: string | undefined;
roles: { id: string; name: string }[];
groups: { id: string; name: string }[];
name: string;
description?: string;
maxDurationMs?: number;
requireJustification?: boolean;
requestable?: boolean;
approverGroupId?: string;
managerApproval?: boolean;
createdAt: number;
updatedAt: number;
id: string;
tenantId: string;
}[];
assignments: PackageAssignmentSummary[];
requests: PackageRequestSummary[];
}>
```
## listRequests [#listrequests]
Lists package requests for a package or a person, newest first, optionally by status.
**HTTP:** `POST /api/iam/packages/listRequests` (requires a credential) · **Browser client:** `client.packages.listRequests()`
* **Permission:** `iam:packages:read` on the package when you pass `packageId`, otherwise on the tenant.
* **Audited as:** `iam:packages:read`.
* **Errors:** `INVALID_INPUT` for an unknown `status`; `NOT_FOUND` when `packageId` is not in this tenant.
A pending request past its lapse time is reported, and filtered, as `expired` even before the purge worker marks it.
```ts title="Signature"
iam.api.packages.listRequests(
credential: CredentialInput,
input: {
tenantId: string;
packageId?: string;
identityId?: string;
status?: PackageRequestStatus;
},
): Promise
```
## previewAutoAssign [#previewautoassign]
Shows what a package rule matches and, for a package, what a reconcile would change, without writing anything.
**HTTP:** `POST /api/iam/packages/previewAutoAssign` (requires a credential) · **Browser client:** `client.packages.previewAutoAssign()`
* **Permission:** `iam:packages:read` on the package (or the tenant without `packageId`), plus `iam:identities:read`
on the tenant to evaluate a rule.
* **Audited as:** `iam:packages:read`.
* **Errors:** `INVALID_INPUT` or `INVALID_POLICY` for a candidate rule that does not validate; `ACCESS_DENIED`
without `iam:identities:read`; `NOT_FOUND` when `packageId` is not in this tenant.
Pass a candidate `autoAssign` to test a rule before saving it, `packageId` alone to inspect the stored rule, or
neither to get only `keys`, the attribute keys a rule may test with their operators. The result counts `matching`,
`excluded`, and `frozen` (disabled or expired) identities and lists a `sample` of matches (20 by default, at most
100\). With `packageId` it adds the `plan` (how many would be assigned, refreshed, restored, ended, or revoked), the
first changes, whether the `brake` would hold them back, and the rule's current `status`. `warnings` flags clauses
that also match service accounts or accept unverified email addresses.
```ts
const preview = await iam.api.packages.previewAutoAssign(credential, {
tenantId,
packageId,
autoAssign: {
include: [
{
StringEqualsIgnoreCase: { 'identity.emailDomain': 'acme.com' },
Bool: { 'identity.emailVerified': true },
StringEquals: { 'principal.kind': 'user' },
},
],
},
});
// preview.plan: { assign, refresh, restore, ending, revoke, manual, keep }
// preview.brake.grants.trips: true when a scheduled run would hold the grants back
```
```ts title="Signature"
iam.api.packages.previewAutoAssign(
credential: CredentialInput,
input: {
tenantId: string;
packageId?: string;
autoAssign?: AutoAssignInput;
sample?: number;
},
): Promise
```
## reconcile [#reconcile]
Runs the package-rule reconciler now for the tenant or one package, optionally confirming held-back changes.
**HTTP:** `POST /api/iam/packages/reconcile` (requires a credential) · **Browser client:** `client.packages.reconcile()`
* **Permission:** `iam:packages:assign` on the package (or the tenant without `packageId`). `confirm: true` also
needs the rights to assign the package by hand.
* **Audited as:** `iam:packages:assign`; each change as `package:auto-assign`, `package:auto-ending`, or
`package:auto-revoke`, and problems as `package:auto-failed`, `package:auto-suspended`, or `package:auto-braked`,
all by `deployment-operator`; a confirmation as `package:auto-confirm` by you.
* **Errors:** `INVALID_INPUT` when `confirm` is given without `packageId` or `limit` is outside 1 to 10000;
`INVALID_TRANSITION` when confirming a package that has no rule; `ACCESS_DENIED` or `GRANT_AUTHORITY_REQUIRED` when
confirming without the rights.
Use it after a bulk import or a configuration apply (which does not reconcile), or to apply changes the brake held
back. Without `packageId` it covers every rule package in the tenant, plus packages whose rule was cleared but that
still have automatic holders. Removals run first, then additions, each change in its own transaction, at most
`limit` changes (1000 by default).
Without `confirm`, a run holds back more than the rule's `maxGrants` (100) new grants or `maxRemovals` (25) removals
per package and reports them in `braked`. `confirm: true` approves the package's planned counts for a day and applies
them in this run. Failures for one person, such as a separation-of-duties conflict, are reported in `failed` and
never stop the run; `truncated: true` means the budget ran out, so run again.
```ts
// A scheduled run held back 140 new grants after a bulk import. Review, then confirm.
const result = await iam.api.packages.reconcile(credential, { tenantId, packageId, confirm: true });
```
```ts title="Signature"
iam.api.packages.reconcile(
credential: CredentialInput,
input: { tenantId: string; packageId?: string; confirm?: boolean; limit?: number },
): Promise
```
## request [#request]
Asks for a requestable package for yourself; approvers are notified and one of them decides.
**HTTP:** `POST /api/iam/packages/request` (requires a credential) · **Browser client:** `client.packages.request()`
* **Permission:** `iam:packages:request` on the package, from an ordinary session of the tenant.
* **Audited as:** `iam:packages:request`, plus `package:request` with the lapse time, requested end, and
justification.
* **Errors:** `INVALID_TRANSITION` when the package is not requestable, or it names approvers but none could act
(an empty approver group and no active manager); `CONFLICT` when you already hold the package or a request of
yours is still pending; `INVALID_INPUT` from a role session or another tenant's session, or when the end or
justification the package requires is missing or out of range; `IMPERSONATION_RESTRICTED` from an impersonation
session.
`expiresAt` is the end you want the access to have, and `justification` the reason, both under the same rules as
`assign`. The request lapses after the tenant's `approvalLifetimeMs`, or at the end you asked for if that comes
first. When the deployment sends email, the approver group's members and, with `managerApproval`, your manager
receive a `package-request` message. Grant `iam:packages:request` through a group everyone belongs to, like
`iam:bindings:activate` for [just-in-time elevation](/docs/guides/privileged-access/elevation).
```ts
const pending = await iam.api.packages.request(memberCredential, {
tenantId,
packageId,
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
justification: 'On call for the payments team next month',
});
// pending.status === 'pending'; pending.expiresAt is when the request lapses
```
```ts title="Signature"
iam.api.packages.request(
credential: CredentialInput,
input: {
tenantId: string;
packageId: string;
expiresAt?: number;
justification?: string;
},
): Promise
```
## revoke [#revoke]
Removes a package from a person, taking away exactly the bindings and memberships the assignment still owns.
**HTTP:** `POST /api/iam/packages/revoke` (requires a credential) · **Browser client:** `client.packages.revoke()`
* **Permission:** `iam:packages:assign` on the package.
* **Audited as:** `iam:packages:assign`, plus `package:revoke` with the removed counts.
* **Errors:** `NOT_FOUND` when the package is not assigned to the person; `INVALID_TRANSITION` for an automatic
assignment while the package still has its rule; `INVARIANT_VIOLATION` when the removal would break an enforced
invariant.
The assignment owns its records whichever authority issued them, so revoking needs no grant authority. Records
taken over by hand stay, and a shared membership passes to another of the person's packages that includes the
group. To stop a rule from giving someone the package, exclude them in the rule (for example
`exclude: [{ StringEquals: { 'principal.id': identityId } }]`) or change their attributes.
```ts title="Signature"
iam.api.packages.revoke(
credential: CredentialInput,
input: { tenantId: string; packageId: string; identityId: string },
): Promise<{ bindings: number; memberships: number; revoked: true }>
```
## update [#update]
Changes a package's name, description, contents, request settings, or rule.
**HTTP:** `POST /api/iam/packages/update` (requires a credential) · **Browser client:** `client.packages.update()`
* **Permission:** `iam:packages:update` on the package. Setting, changing, or clearing a rule, or changing a rule
package's roles or groups, also needs `iam:packages:assign` and, except when clearing, the rule-owner checks of
`create`.
* **Audited as:** `iam:packages:update`, plus `package:auto-rule` (`set`, `change`, `owner`, `contents`, or `clear`)
when the rule changes.
* **Errors:** `CONFLICT` for a name another package uses; `INVALID_INPUT` when the rule no longer fits new contents,
`keepAutomaticAssignments` is used without clearing a rule or with more than 5000 automatic holders, or
`maxDurationMs` is set on a rule package; plus the validation and owner-check errors of `create`.
Manual assignments keep what they were given; contents changes apply to future assignments only. Automatic
assignments follow the package: the reconcile that runs after the save (up to 200 changes, the rest at the next
run) adds and removes their records. Changing a rule package's roles or groups makes you the rule's owner, so a
role added later is always granted under the authority of the person who added it. `autoAssign: null` clears the
rule, and reconciliation then removes the automatic holders unless you pass `keepAutomaticAssignments: true`, which
turns them into manual assignments. Pass `null` to clear `description`, `maxDurationMs`, or `approverGroupId`.
Tightening a package cancels the pending requests it no longer allows, with the reason as the note: turning
`requestable` off, requiring a justification a request lacks, or capping the duration below what a request asked for.
```ts title="Signature"
iam.api.packages.update(
credential: CredentialInput,
input: { tenantId: string; packageId: string } & PackageUpdate,
): Promise
```
# policies (/docs/reference/api/policies)
> Policies are named, versioned policy documents that roles attach to grant access.
Policies are named, versioned policy documents that roles attach to grant access. A policy grants nothing on its
own: it takes effect when a role lists it in `policyIds` and that role is bound to someone. Storing a document as a
policy, instead of inline in a role, lets several roles share it, keeps every change as a version you can list and
restore, and lets editors test a draft before saving it. This group also holds the administrator-only review reads
(`simulate`, `whoCan`, `effectiveActions`) that explain access without creating a session or granting anything.
The document format itself is described in [policy documents](/docs/guides/authorization/policies).
## Versions and edit rights [#versions-and-edit-rights]
* **Every change is a version.** A policy starts at version 1. Each `update` or `restoreVersion` archives the
current version and saves the next one, so `listVersions` always shows the full history.
* **Optimistic concurrency.** `update` takes the `version` you read and fails with `VERSION_CONFLICT` when someone
saved in between, so two editors cannot silently overwrite each other.
* **Edits stay with their authority.** A policy records the [grant authority](/docs/guides/authorization/roles#grant-authorities)
it was created under. Only that authority's holder, or root, may update, restore, or delete it, and its ceiling
keeps bounding the policy wherever it is attached, even when a higher authority attaches it to a role.
* **Validated against the catalog.** Documents are checked when they are created, updated, or restored:
`INVALID_POLICY` for a malformed document, `INVALID_ACTION` for an unknown action, and `INVALID_RESOURCE_TYPE` for
an unknown resource type.
* **The Owner policy is protected.** The system Owner policy behind every tenant's Owner role cannot be edited,
restored, or deleted (`PROTECTED_RESOURCE`).
## Reading a review result [#reading-a-review-result]
`simulate`, `whoCan`, and `effectiveActions` evaluate an identity in a synthetic session that is never issued: a
user session (an API-key session for service accounts), without MFA unless you pass `assumeMfa: true`. The ordinary
evaluator runs, so conditions, boundaries, authority ceilings, access windows, relationships, and just-in-time
eligibility (only live activations count) all apply. Results are advisory: they explain access, they never enforce
it. See [access reviews](/docs/guides/authorization/reviews).
| Reason | Meaning |
| --------------------- | -------------------------------------------------------------------------------------------- |
| `allowed` | A role grants the action and no deny or boundary blocks it. |
| `explicit-deny` | A deny statement in one of the identity's roles matched. |
| `boundary-deny` | A tenant boundary, principal boundary, or session policy does not allow the action. |
| `NO_APPLICABLE_GRANT` | No role grants the action within its authority ceilings. |
| `no-grant` | Returned by `test` only: the tested document does not allow the action. |
| `ROOT_OVERRIDE` | The identity is a platform root administrator evaluated with MFA; the root override applies. |
| `TENANT_INACTIVE` | The tenant or one of its ancestors is not active. |
| `UNKNOWN_ACTION` | Returned by `simulate` only: the action is not in the catalog. |
| Method | What it does | Access |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------- |
| [`create`](#create) | Stores a new policy document at version 1 under your grant authority. | Credential |
| [`delete`](#delete) | Deletes a policy that no role attaches. | Credential |
| [`effectiveActions`](#effectiveactions) | Lists which actions one identity could perform on one resource, with a reason per action. | Credential |
| [`get`](#get) | Returns one policy with its current document and version. | Credential |
| [`list`](#list) | Lists every policy in the tenant at its current version, including the protected Owner policy. | Credential |
| [`listVersions`](#listversions) | Returns a policy's full history, oldest first, ending with the current version. | Credential |
| [`restoreVersion`](#restoreversion) | Rolls a policy back to an earlier document by saving that document as a new version. | Credential |
| [`simulate`](#simulate) | Explains the decision one identity would get for one action on one resource, without creating a session or granting anything. | Credential |
| [`test`](#test) | Evaluates an unsaved policy document against one action, resource, and context, for policy editors. | Credential |
| [`update`](#update) | Saves a new version of a policy's document, name, or description, keeping the previous version in its history. | Credential |
| [`whoCan`](#whocan) | Lists every active identity that could perform an action on a resource, with the reason, for access reviews. | Credential |
## create [#create]
Stores a new policy document at version 1 under your grant authority.
**HTTP:** `POST /api/iam/policies/create` (requires a credential) · **Browser client:** `client.policies.create()`
* **Permission:** `iam:policies:create` on the tenant, plus an active grant authority.
* **Audited as:** `iam:policies:create`.
* **Errors:** `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE` when the document does not validate
against the catalog; `GRANT_AUTHORITY_REQUIRED` when you hold no active grant authority; `LIMIT_EXCEEDED` when the
tenant's plan limit for policies is reached; `INVALID_INPUT` for an empty name or a description over 512
characters.
The new policy grants nothing until a role attaches it with
[`roles.create`](/docs/reference/api/roles#create) or [`roles.update`](/docs/reference/api/roles#update). Try a
draft with `test` first.
```ts
const readOwn = await iam.api.policies.create(credential, {
tenantId,
name: 'Read own documents',
document: {
version: 1,
statements: [
{
sid: 'OwnedDocuments',
effect: 'allow',
actions: ['documents:read'],
resources: ['document/*'],
conditions: { StringEquals: { 'resource.ownerId': '${principal.id}' } },
},
],
},
});
```
```ts title="Signature"
iam.api.policies.create(
credential: CredentialInput,
input: PolicyInput,
): Promise
```
## delete [#delete]
Deletes a policy that no role attaches.
**HTTP:** `POST /api/iam/policies/delete` (requires a credential) · **Browser client:** `client.policies.delete()`
* **Permission:** `iam:policies:delete` on the policy, and the policy's grant authority (or root).
* **Audited as:** `iam:policies:delete`.
* **Errors:** `RESOURCE_IN_USE` (409) while any role still attaches the policy; `PROTECTED_RESOURCE` for the Owner
policy; `ACCESS_DENIED` when another administrator's authority created it; `NOT_FOUND`.
Detach the policy first by updating each role's `policyIds`. The refusal exists so that deleting a policy never
silently removes access from the roles built on it.
```ts title="Signature"
iam.api.policies.delete(
credential: CredentialInput,
input: { tenantId: string; policyId: string },
): Promise<{ deleted: boolean }>
```
## effectiveActions [#effectiveactions]
Lists which actions one identity could perform on one resource, with a reason per action.
**HTTP:** `POST /api/iam/policies/effectiveActions` (requires a credential) · **Browser client:** `client.policies.effectiveActions()`
* **Permission:** `iam:policies:simulate` on the identity (`iam/{identityId}`).
* **Audited as:** `iam:policies:simulate`.
* **Errors:** `INVALID_INPUT` for more than 200 `actions`; `INVALID_ACTION` when one of them is not in the catalog;
`NOT_FOUND` when the identity is not in this tenant or a managed resource is not registered;
`RESOURCE_RESOLVER_REQUIRED`.
Without `actions`, every action in the catalog is checked: the built-in `iam:*` actions, your product's and plugins'
actions, and tenant-defined ones. The identity's grants are loaded once, so this is cheaper than calling `simulate`
per action. The result has `allowed` (the sorted action names) and `results` (each action with `allowed` and
`reason`), which is what a "what can this person do here?" panel needs.
```ts title="Signature"
iam.api.policies.effectiveActions(
credential: CredentialInput,
input: {
tenantId: string;
identityId: string;
resource: { type: string; id: string };
actions?: string[];
assumeMfa?: boolean;
},
): Promise<{
allowed: string[];
results: { action: string; allowed: boolean; reason: string }[];
}>
```
## get [#get]
Returns one policy with its current document and version.
**HTTP:** `POST /api/iam/policies/get` (requires a credential) · **Browser client:** `client.policies.get()`
* **Permission:** `iam:policies:read` on the policy.
* **Audited as:** `iam:policies:read`.
* **Errors:** `NOT_FOUND` when the policy is not in this tenant.
```ts title="Signature"
iam.api.policies.get(
credential: CredentialInput,
input: { tenantId: string; policyId: string },
): Promise
```
## list [#list]
Lists every policy in the tenant at its current version, including the protected Owner policy.
**HTTP:** `POST /api/iam/policies/list` (requires a credential) · **Browser client:** `client.policies.list()`
* **Permission:** `iam:policies:read` on the tenant.
* **Audited as:** `iam:policies:read`.
```ts title="Signature"
iam.api.policies.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise
```
## listVersions [#listversions]
Returns a policy's full history, oldest first, ending with the current version.
**HTTP:** `POST /api/iam/policies/listVersions` (requires a credential) · **Browser client:** `client.policies.listVersions()`
* **Permission:** `iam:policies:read` on the policy.
* **Audited as:** `iam:policies:read`.
* **Errors:** `NOT_FOUND` when the policy is not in this tenant.
Each entry carries the `document`, `name`, and `version` as they were. Archived entries have their own record `id`
and point back to the policy through `policyId`, so pick a version for `restoreVersion` by its `version` number.
```ts title="Signature"
iam.api.policies.listVersions(
credential: CredentialInput,
input: { tenantId: string; policyId: string },
): Promise
```
## restoreVersion [#restoreversion]
Rolls a policy back to an earlier document by saving that document as a new version.
**HTTP:** `POST /api/iam/policies/restoreVersion` (requires a credential) · **Browser client:** `client.policies.restoreVersion()`
* **Permission:** `iam:policies:update` on the policy, and the policy's grant authority (or root).
* **Audited as:** `iam:policies:update`.
* **Errors:** `INVALID_INPUT` when `version` is not between 1 and the current version, or is the current version;
`NOT_FOUND` when that version is not in the history; `INVALID_ACTION` or `INVALID_RESOURCE_TYPE` when the old
document no longer fits the catalog; `PROTECTED_RESOURCE`; `ACCESS_DENIED`; `INVARIANT_VIOLATION`.
History is never rewritten: restoring version 3 of a policy at version 7 saves version 8 with version 3's
document. Only the document is restored; the name and description stay as they are. The old document is validated
again because actions or resource types may have been removed since it was written.
```ts title="Signature"
iam.api.policies.restoreVersion(
credential: CredentialInput,
input: { tenantId: string; policyId: string; version: number },
): Promise<{
document: PolicyDocument;
version: number;
name: string;
description?: string;
id: string;
tenantId: string;
uniqueKey?: string;
}>
```
## simulate [#simulate]
Explains the decision one identity would get for one action on one resource, without creating a session or granting anything.
**HTTP:** `POST /api/iam/policies/simulate` (requires a credential) · **Browser client:** `client.policies.simulate()`
* **Permission:** `iam:policies:simulate` on the identity (`iam/{identityId}`).
* **Audited as:** `iam:policies:simulate`.
* **Errors:** `NOT_FOUND` when the identity is not in this tenant or a managed resource is not registered;
`RESOURCE_RESOLVER_REQUIRED` for an application-owned resource type when no `resolveResource` is configured;
`RESOURCE_MISMATCH` when the resolver returns a record of another tenant or resource.
Use it when support asks "why can't this person open this?". Unlike the public authorization calls, the result
includes `matched`, the statements that decided. An action missing from the catalog returns `allowed: false` with
reason `UNKNOWN_ACTION` rather than an error. For `iam:*` actions, pass platform resources such as
`{ type: 'iam', id: roleId }`.
```ts
const decision = await iam.api.policies.simulate(credential, {
tenantId,
identityId: alice.id,
action: 'invoices:approve',
resource: { type: 'invoice', id: 'inv_2041' },
assumeMfa: true,
});
// { allowed: false, reason: 'explicit-deny', matched: [...] }
```
```ts title="Signature"
iam.api.policies.simulate(
credential: CredentialInput,
input: {
tenantId: string;
identityId: string;
action: string;
resource: { type: string; id: string };
assumeMfa?: boolean;
},
): Promise
```
## test [#test]
Evaluates an unsaved policy document against one action, resource, and context, for policy editors.
**HTTP:** `POST /api/iam/policies/test` (requires a credential) · **Browser client:** `client.policies.test()`
* **Permission:** `iam:policies:simulate` on the tenant.
* **Audited as:** `iam:policies:simulate`.
* **Errors:** `INVALID_POLICY`, `INVALID_ACTION`, or `INVALID_RESOURCE_TYPE` when the document does not validate;
`INVALID_INPUT` when `context` has more than 200 keys or `resource` is over 2048 characters.
Only the document is evaluated: no identity, roles, bindings, or boundaries are involved, so it answers "does this
document say what I mean?". `resource` is a `type/id` string. The context starts with the keys a session issued now
would carry (`resource.tenantId` and `principal.tenantId` set to the tenant, `principal.sessionId`,
`principal.tokenIssueTime`, `principal.authTime`, `principal.sessionTagKeys`, and `request.time`); your `context`
adds keys or overrides them. Add `'principal.mfa': true` to test an MFA condition and `principal.id` to resolve
`${principal.id}` variables. The result is a full decision with `matched`, the statements that matched as
`grant:{index}:{sid}` (the statement's position when it has no `sid`).
```ts
const decision = await iam.api.policies.test(credential, {
tenantId,
document: draft,
action: 'documents:read',
resource: 'document/plan-2027',
context: { 'principal.id': 'usr_123', 'resource.ownerId': 'usr_123' },
});
// { allowed: true, reason: 'allowed', matched: ['grant:0:OwnedDocuments'] }
```
```ts title="Signature"
iam.api.policies.test(
credential: CredentialInput,
input: {
tenantId: string;
document: PolicyDocument;
action: string;
resource: string;
context?: Record;
},
): Promise
```
## update [#update]
Saves a new version of a policy's document, name, or description, keeping the previous version in its history.
**HTTP:** `POST /api/iam/policies/update` (requires a credential) · **Browser client:** `client.policies.update()`
* **Permission:** `iam:policies:update` on the policy, and the grant authority the policy was created under (or
root).
* **Audited as:** `iam:policies:update`.
* **Errors:** `VERSION_CONFLICT` (409) when `version` is not the current version; `ACCESS_DENIED` when another
administrator's authority created the policy; `PROTECTED_RESOURCE` for the Owner policy; `INVALID_INPUT` when
none of `document`, `name`, or `description` is given; `INVALID_POLICY`, `INVALID_ACTION`, or
`INVALID_RESOURCE_TYPE` for the new document; `GRANT_AUTHORITY_REQUIRED`; `NOT_FOUND`; `INVARIANT_VIOLATION` when
the change would newly break an enforced [access invariant](/docs/reference/api/invariants).
Pass the `version` you read. Renaming also creates a new version. The change applies at the next request to every
role that attaches the policy, so preview it first with [`impact.preview`](/docs/reference/api/impact#preview).
```ts
const current = await iam.api.policies.get(credential, { tenantId, policyId });
await iam.api.policies.update(credential, {
tenantId,
policyId,
version: current.version,
document: {
version: 1,
statements: [
...current.document.statements,
{ effect: 'deny', actions: ['documents:delete'], resources: ['document/*'] },
],
},
});
```
```ts title="Signature"
iam.api.policies.update(
credential: CredentialInput,
input: PolicyUpdate,
): Promise
```
## whoCan [#whocan]
Lists every active identity that could perform an action on a resource, with the reason, for access reviews.
**HTTP:** `POST /api/iam/policies/whoCan` (requires a credential) · **Browser client:** `client.policies.whoCan()`
* **Permission:** `iam:policies:simulate` on the tenant.
* **Audited as:** `iam:policies:simulate`.
* **Errors:** `INVALID_ACTION` for an action missing from the catalog; `INVALID_INPUT` for a `kind` other than
`user` or `service`, a `limit` outside 1 to 1000, or an invalid `offset`; `NOT_FOUND` when a managed resource is
not registered; `RESOURCE_RESOLVER_REQUIRED`.
Every active identity of the tenant is evaluated, people and service accounts alike; `kind` narrows to one. The
result has `identities` (id, name, email, kind, and the decision reason) and `total`, the number of matches before
paging with `limit` (100 by default) and `offset`. Grants are loaded per identity, so the cost grows with the
directory: use it on review screens, not on every request.
```ts title="Signature"
iam.api.policies.whoCan(
credential: CredentialInput,
input: {
tenantId: string;
action: string;
resource: { type: string; id: string };
kind?: 'user' | 'service';
assumeMfa?: boolean;
limit?: number;
offset?: number;
},
): Promise<{ identities: ReviewMatch[]; total: number }>
```
# relationships (/docs/reference/api/relationships)
> Relationships record that a person or group stands in a named relation, such as owner or viewer, to one resource.
Relationships record that a person or group stands in a named relation, such as `owner` or `viewer`, to one resource.
*Alice is an `owner` of `folder/plans`*; *the design group are `viewer`s of `folder/plans`*. They express per-resource
sharing and ownership (relationship-based access control) without writing resource ids into policies: one role says
"viewers may read", and sharing a folder is a single tuple instead of a policy edit. The
[relationships guide](/docs/guides/authorization/relationships) shows the full pattern.
## How policies read relations [#how-policies-read-relations]
A tuple is `{type}/{id}#{relation}@{subjectType}:{subjectId}`. The relation must be declared on the resource type
(`relations` in `permissions.resourceTypes`, or on a
[tenant-defined type](/docs/reference/api/resource-types#register)). The subject is an identity or a group; a
group's tuples apply to its current members.
When a decision is made, the caller's live relations on the evaluated resource appear as `resource.relations` (a
sorted array of names) and those on its registered parent as `resource.parentRelations`. Test them with
`ArrayContains`:
```ts
{ effect: 'allow', actions: ['files:read'], resources: ['file/*'],
conditions: { ArrayContains: { 'resource.parentRelations': ['viewer', 'editor', 'owner'] } } }
```
Administrative calls on `iam/{type}/{id}` see the relations on the named resource too, which is how an owner can share
their own folder without a tenant-wide administrator role: grant `iam:relationships:create` on `iam/folder/*` under the
condition `ArrayContains: { 'resource.relations': ['owner'] }`. Role sessions hold no relations. Expired tuples stop
counting at once and are removed later by `iam.sweepExpired()`. Tuples are also removed with their identity, their
group, or their managed resource. [`listAccessible`](/docs/reference/api#listaccessible) takes relations into account.
| Method | What it does | Access |
| ------------------- | ---------------------------------------------------------------------------------------------------- | ---------- |
| [`create`](#create) | Gives an identity or group a declared relation on one resource, optionally until a given time. | Credential |
| [`delete`](#delete) | Removes one relationship tuple, ending the access it gave. | Credential |
| [`list`](#list) | Lists relationship tuples of one resource, one subject, one type, or the whole tenant, newest first. | Credential |
## create [#create]
Gives an identity or group a declared relation on one resource, optionally until a given time.
**HTTP:** `POST /api/iam/relationships/create` (requires a credential) · **Browser client:** `client.relationships.create()`
* **Permission:** `iam:relationships:create` on `iam/{type}/{id}`.
* **Audited as:** `iam:relationships:create`, on `{type}/{id}`.
* **Errors:** `INVALID_RESOURCE_TYPE` when the type is not declared; `INVALID_INPUT` when the relation is not declared
for the type, the subject type is not `identity` or `group`, or `expiresAt` is not in the future (at most ten years
out); `NOT_FOUND` when a managed resource is not registered, the identity is not in this tenant or was deleted, or
the group is not in this tenant; `INVARIANT_VIOLATION` when an enforced
[access invariant](/docs/guides/governance/change-safety) would newly fail.
Resources of managed types must be registered first; resources of application-owned types are accepted as named.
Creating a tuple that already exists replaces it instead of failing: its `expiresAt` becomes the one you pass (none
makes it permanent) and the caller is recorded as `createdBy`. Use `expiresAt` for time-boxed sharing, such as giving
an auditor `viewer` on a folder for a week.
```ts
await iam.api.relationships.create(credential, {
tenantId,
type: 'folder',
id: 'plans',
relation: 'viewer',
subjectType: 'group',
subjectId: designGroupId,
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
});
```
```ts title="Signature"
iam.api.relationships.create(
credential: CredentialInput,
input: {
tenantId: string;
type: string;
id: string;
relation: string;
subjectType: 'identity' | 'group';
subjectId: string;
expiresAt?: number;
},
): Promise
```
## delete [#delete]
Removes one relationship tuple, ending the access it gave.
**HTTP:** `POST /api/iam/relationships/delete` (requires a credential) · **Browser client:** `client.relationships.delete()`
* **Permission:** `iam:relationships:delete` on the tuple's resource, `iam/{type}/{id}`.
* **Audited as:** `iam:relationships:delete`, on `{type}/{id}`.
* **Errors:** `NOT_FOUND` when the tuple is not in this tenant; `INVARIANT_VIOLATION` when an enforced access
invariant would newly fail.
Pass the tuple's `id`, as returned by [`create`](#create) or [`list`](#list). The change applies to the next
decision.
```ts title="Signature"
iam.api.relationships.delete(
credential: CredentialInput,
input: { tenantId: string; relationshipId: string },
): Promise<{ deleted: boolean }>
```
## list [#list]
Lists relationship tuples of one resource, one subject, one type, or the whole tenant, newest first.
**HTTP:** `POST /api/iam/relationships/list` (requires a credential) · **Browser client:** `client.relationships.list()`
* **Permission:** `iam:relationships:read` on `iam/{type}/{id}` when `type` and `id` are given, on `iam/{type}/*`
when only `type` is, otherwise on `iam/*`.
* **Audited as:** `iam:relationships:read`.
* **Errors:** `INVALID_INPUT` for a subject type other than `identity` or `group`.
Filter by `type`, `id`, `relation`, `subjectType`, and `subjectId` in any combination: "who can see this folder" is
`{ type, id }`, and "what has been shared with this group" is `{ subjectType: 'group', subjectId }`. Expired tuples
are left out unless `includeExpired` is `true`. Because the permission is checked on the resource, an owner allowed to
read relationships on their own folder can review who it is shared with.
```ts title="Signature"
iam.api.relationships.list(
credential: CredentialInput,
input: {
tenantId: string;
type?: string;
id?: string;
relation?: string;
subjectType?: 'identity' | 'group';
subjectId?: string;
includeExpired?: boolean;
},
): Promise
```
# reports (/docs/reference/api/reports)
> The access report gathers a tenant's access-lifecycle state in one document: what is about to end, who is elevated right now, and which API keys nobody uses.
The access report gathers a tenant's access-lifecycle state in one document: what is about to end, who is elevated
right now, and which API keys nobody uses. Temporary access only stays safe if someone notices what is expiring,
what is pending, and what was forgotten, and checking each list by hand does not scale. Run the report nightly and
route it to a channel or ticketing system. See [the access report](/docs/guides/privileged-access/access-report).
| Method | What it does | Access |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
| [`access`](#access) | Returns the tenant's access report: identities and grants ending soon, live elevations, pending requests, and unused or expiring API keys. | Credential |
## access [#access]
Returns the tenant's access report: identities and grants ending soon, live elevations, pending requests, and unused or expiring API keys.
**HTTP:** `POST /api/iam/reports/access` (requires a credential) · **Browser client:** `client.reports.access()`
* **Permission:** `iam:identities:read` on the tenant. The bindings section also needs `iam:bindings:read` and the
credentials section `iam:credentials:read` on the tenant; without them the section is left out, not refused.
* **Audited as:** `iam:identities:read`.
* **Errors:** `INVALID_INPUT` when `withinMs` or `unusedForMs` is negative or longer than ten years.
`withinMs` (default 30 days) is the look-ahead window for things that end or start, and `unusedForMs` (default 30
days) is how long an API key must go unused to be listed. The report has three sections:
* **`identities`**: how many identities the tenant has (deleted ones excluded) and how many are disabled, plus
people and service accounts whose `expiresAt` falls within the window. An identity already past its deadline
stays listed with `expired: true` until the retention worker disables it.
* **`bindings`**: how many unexpired bindings there are, and how many of them are eligible or limited to an access
window; bindings that expire within the window; future-dated bindings that start within it; temporary group
memberships that end within it; live just-in-time activations with their justification; and the number of
activation requests awaiting a decision.
* **`credentials`**: the number of unexpired API keys, keys not used for `unusedForMs` (never-used keys count from
their creation), and keys that expire within the window.
`omitted` names the sections the caller could not read, so a directory administrator without
`iam:credentials:read` still gets the rest. For email delivery to tenant owners, schedule
[`iam.sendAccessDigest()`](/docs/reference/api#sendaccessdigest) instead; the `report`
[CLI command](/docs/reference/cli#report) prints the same report.
```ts
const report = await iam.api.reports.access(credential, { tenantId, withinMs: 14 * 24 * 60 * 60 * 1000 });
for (const binding of report.bindings?.expiring ?? [])
console.log(`${binding.subjectName ?? binding.subjectId} loses ${binding.roleName ?? binding.roleId}`);
```
```ts title="Signature"
iam.api.reports.access(
credential: CredentialInput,
input: { tenantId: string; withinMs?: number; unusedForMs?: number },
): Promise
```
# resourceTypes (/docs/reference/api/resource-types)
> Resource types let a tenant describe its own kinds of resources at runtime, with their actions, typed attributes, relations, and parent.
Resource types let a tenant describe its own kinds of resources at runtime, with their actions, typed attributes,
relations, and parent. Platform types come from your configuration (`permissions.resourceTypes`) and plugins; this
group lists them all and, when the deployment sets `permissions.mode: 'tenant-defined'`, lets tenant administrators
add types of their own without a redeploy. That suits products where each customer models different objects, such as
a workflow tool where one organization tracks "contracts" and another "incidents". The
[catalog guide](/docs/guides/authorization/catalog#tenant-defined-catalogs) explains the model.
## What a tenant-defined type declares [#what-a-tenant-defined-type-declares]
A tenant-defined type is always **managed**: its resources are registered through
[`resources.register`](/docs/reference/api/resources#register) and authorization reads them without an application
callback. It declares:
* `name`: lowercase letters, digits, and hyphens, starting with a letter, at most 64 characters. It cannot be a
reserved name (`iam`, `role`, `tenant`, `identity`, `session`, `oauth-client`, `scim`, `saml`, `ssf`), a platform
type, or the namespace of any platform action (for example `documents` when `documents:read` exists).
* `actions`: verbs. Each becomes the action `{name}:{verb}`, such as `contract:approve`. Verbs start with a letter and
use letters, digits, `_`, or `-`.
* `attributes`: at most 64 typed attributes (`string`, `number`, or `boolean`) that registered resources may carry and
policies read as `resource.{name}`.
* `relations`: at most 32 lowercase relation names that [relationship tuples](/docs/reference/api/relationships) may
use on resources of the type.
* `parent` (optional): an existing managed type. Resources of the type must then be registered under a parent
resource, and policies can read the caller's relations on that parent.
Defining a type or action grants nothing. Access still comes from roles and policies that name the new actions. Types
can also be managed as code with [configuration sync](/docs/guides/privileged-access/config-as-code).
| Method | What it does | Access |
| ----------------------- | ----------------------------------------------------------------------------------------------------------- | ---------- |
| [`delete`](#delete) | Deletes a tenant-defined resource type and its actions. | Credential |
| [`get`](#get) | Returns one resource type, platform or tenant-defined, with its actions, attributes, relations, and parent. | Credential |
| [`list`](#list) | Lists every resource type the tenant can use: platform types first, then the tenant's own. | Credential |
| [`register`](#register) | Defines a new resource type for the tenant, and registers its actions in the same transaction. | Credential |
| [`update`](#update) | Changes a tenant-defined type's description, attribute schema, or relations, and adds action verbs. | Credential |
## delete [#delete]
Deletes a tenant-defined resource type and its actions.
**HTTP:** `POST /api/iam/resourceTypes/delete` (requires a credential) · **Browser client:** `client.resourceTypes.delete()`
* **Permission:** `iam:resource-types:delete` on the tenant.
* **Audited as:** `iam:resource-types:delete`.
* **Errors:** `NOT_FOUND` when no tenant-defined type has that name (platform types cannot be deleted);
`RESOURCE_IN_USE` while resources of the type are registered, relationship tuples reference it, another type names it
as its parent, or a stored policy or inline role document still names one of its actions.
The in-use checks keep deletion from silently changing access: remove the resources, relationships, child types, and
policy references first.
```ts title="Signature"
iam.api.resourceTypes.delete(
credential: CredentialInput,
input: { tenantId: string; name: string },
): Promise<{ deleted: boolean }>
```
## get [#get]
Returns one resource type, platform or tenant-defined, with its actions, attributes, relations, and parent.
**HTTP:** `POST /api/iam/resourceTypes/get` (requires a credential) · **Browser client:** `client.resourceTypes.get()`
* **Permission:** `iam:resource-types:read` on the tenant.
* **Audited as:** `iam:resource-types:read`.
* **Errors:** `NOT_FOUND` when the name is unknown to this tenant.
`source` is `platform` or `tenant`, and `managed` says whether resources of the type are registered with IAM or
resolved by the application.
```ts title="Signature"
iam.api.resourceTypes.get(
credential: CredentialInput,
input: { tenantId: string; name: string },
): Promise
```
## list [#list]
Lists every resource type the tenant can use: platform types first, then the tenant's own.
**HTTP:** `POST /api/iam/resourceTypes/list` (requires a credential) · **Browser client:** `client.resourceTypes.list()`
* **Permission:** `iam:resource-types:read` on the tenant.
* **Audited as:** `iam:resource-types:read`.
Use it to build policy editors and resource pickers. Platform types include application-owned ones (`managed: false`),
which exist for validation and documentation but are not registered through the API.
```ts title="Signature"
iam.api.resourceTypes.list(
credential: CredentialInput,
input: { tenantId: string },
): Promise