BetterIAM
Server API

workflows

Identity lifecycle workflows: joiner, mover and leaver automation.

Identity lifecycle workflows: joiner, mover and leaver automation. A workflow has a trigger (a person joined, a watched attribute changed, the person was disabled, a date they carry came round, or someone started it by hand), a scope in the access-package rule language, and up to 20 steps that run for each person the trigger fires for: group and package changes, emails, sign-outs, disabling, enabling, attribute and expiry changes, deletion, events for webhooks, and waits. Steps run with the rights of the administrator who saved the workflow (its owner when the run started), checked again at every step, and a daily brake holds back a workflow that would start more runs than expected. See the lifecycle workflows guide; the repository guide is docs/workflows.md.

Triggers and scope

trigger is one of { kind: 'joiner' } (active people created after the workflow was enabled, or everyone with includeExisting), { kind: 'mover', attributes } (1 to 20 declared identity attributes or managerId, compared with a baseline taken when the workflow starts), { kind: 'leaver' } (an active person was disabled), { kind: 'date', attribute, offsetDays } (offsetDays, -365 to 3650, after createdAt, expiresAt or a declared string attribute holding an ISO 8601 date, or a date and time with its offset; dates more than a day before the workflow started never fire, and a date that moves fires again) and { kind: 'manual' } (only run). Each person runs each occurrence once (until the workflow restarts): the joining, the nth move or departure, the target day of a date. Only people are subjects, never service accounts or agents.

scope is { include, exclude? }, each a list of up to 10 policy condition sets over the keys of package rules: principal.id, principal.kind, principal.owner, declared attributes as principal.NAME, identity.email, identity.emailDomain, identity.emailVerified, identity.managerId, identity.groups, identity.teams and identity.departments. Without a scope a workflow applies to every person. The scope is checked when the trigger fires; run ignores it.

Steps

Each step is { kind, ...fields }, and the owner needs the permission shown, on the person (iam/IDENTITY_ID) or the group or package named:

StepFieldsPermission
add-to-groupgroupId, days? (1 to 3650)iam:groups:update on the group
remove-from-groupgroupIdiam:groups:update on the group
remove-from-all-groupsnoneiam:groups:update on each group, checked as it runs; team and package memberships are left alone
assign-packagepackageId, days?iam:packages:assign on the package, plus what assigning it by hand needs
revoke-packagespackageId?iam:packages:assign on the package, or on each package revoked; assignments a package rule made are left to the rule
send-emailto (subject, manager, or an address of a member or a verified domain), subject, bodyiam:identities:read
revoke-sessions, disable, enablenoneiam:identities:update
set-attributesattributes (declared ones; null clears)iam:identities:update
set-expirydays (1 to 3650, or null)iam:identities:update
deletenone; last step onlyiam:identities:delete
emit-eventnamenone
waithours (1 to 8760); at most 5, never lastnone

Emails fill {name}, {email}, {organization}, {workflow} and {attribute.NAME} and are sent as the workflow-message template; they are skipped without an email delivery callback. remove-from-all-groups, revoke-packages, revoke-sessions, disable and delete take access away: saving, running or retrying such a workflow needs a recent sign-in, and its brake defaults to 25 runs a day instead of 200. These steps, set-attributes and set-expiry never act on owners or root administrators (PROTECTED_RESOURCE) or on the run's owner (INVALID_INPUT), and set-attributes, set-expiry and enable never change the account of the run's owner or of whoever started or retried it (ACCESS_DENIED).

Owner and runs

Saving makes the caller the owner. The caller must act in their own right (a person's own session or an API key, never a role session, session token, delegated session or impersonation), belong to the organization, and hold every step's permission (on the tenant, and on the groups and packages named), or the save answers ACCESS_DENIED. The workflow keeps whether the owner's session had a second factor, and runs are decided with that principal.mfa.

A run records the owner when it starts and uses that owner's rights until it ends: a later editor never lends their rights to runs already started. Each step is authorized again as the run's owner on the person: a missing permission fails the run with ACCESS_DENIED, and an owner who is disabled, deleted or expired fails it with OWNER_INACTIVE. retryRun makes the caller the run's owner. A run (pending, running, waiting, completed, failed, cancelled) keeps the steps it started with and a result per step (done, skipped or failed, with a detail and, on failure, a code); finished runs are swept 180 days after they end. Runs are started and carried on by the scheduler job iam.workflows.runDue() (every few minutes) and by iam.workflows.subscribe(), which reacts to changes to people within moments while iam.dispatchAuditHooks() runs.

Permissions

iam:workflows:manage covers create, update and delete; iam:workflows:read covers list, get, preview, listRuns and getRun; iam:workflows:run covers run, retryRun, cancelRun and evaluate. Actions are checked on iam/TENANT_ID for create, list, listRuns and evaluate, on the workflow (iam/WORKFLOW_ID) for update, delete, get, preview and run, and on the run (iam/RUN_ID) for getRun, cancelRun and retryRun. The readers name people (ownerName, identityName, and names and emails in preview) only for callers who also hold iam:identities:read on the tenant; otherwise they list IDs.

Methods12
Serveriam.api.workflows
Clientclient.workflows
HTTPPOST /api/iam/workflows/*
MethodWhat it doesAccess
cancelRunStops a pending, running, waiting or failed run; steps already done stay done.Credential
createCreates a workflow owned by the caller, who must hold every permission its steps use.Credential
deleteDeletes a workflow and cancels its pending, running and waiting runs; finished runs stay as history.Credential
evaluateEvaluates this organization's workflows and runs whatever is due now, instead of waiting for the scheduler.Credential
getReturns one workflow with run statistics and its 50 most recent runs.Credential
getRunReturns one run with its steps and results.Credential
listLists the tenant's workflows by name, with run statistics.Credential
listRunsLists runs, newest first, optionally of one workflow, one person or one status.Credential
previewShows what a workflow would do right now, without changing anything.Credential
retryRunResumes a failed run at the step that failed, with the caller's rights.Credential
runRuns a workflow now for up to 100 people you choose, whatever its trigger and scope.Credential
updateEdits a workflow; the caller becomes its owner and must hold the steps' permissions.Credential

cancelRun

Stops a pending, running, waiting or failed run; steps already done stay done.

POST/api/iam/workflows/cancelRun
client.workflows.cancelRun()Credential
  • Permission: iam:workflows:run on the run.
  • Audited as: iam:workflows:run.
  • Errors: INVALID_TRANSITION (409) when the run already completed or was cancelled; NOT_FOUND when the run is not in this tenant.

Cancel a leaver's waiting run when they come back before the account is deleted: the run does not check whether the person was enabled again. The result is the cancelled run. To cancel every run of a workflow while changing it, pass activeRuns: 'cancel' to update.

const { runs } = await iam.api.workflows.listRuns(admin, { tenantId, identityId, status: 'waiting' });
for (const run of runs) await iam.api.workflows.cancelRun(admin, { tenantId, runId: run.id });
Input

Prop

Type

Returns

A WorkflowRunView object:

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/cancelRun" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "runId": "<runId>"
}'
Signature
iam.api.workflows.cancelRun(
  credential: CredentialInput,
  input: { tenantId: string; runId: string },
): Promise<WorkflowRunView>

create

Creates a workflow owned by the caller, who must hold every permission its steps use.

POST/api/iam/workflows/create
client.workflows.create()Credential

Used inLifecycle workflows

  • Permission: iam:workflows:manage on the tenant, plus each step's permission on the tenant, group or package, and a recent sign-in when a step takes access away.
  • Audited as: iam:workflows:manage.
  • Errors: INVALID_INPUT for an invalid trigger, scope or steps (an undeclared attribute, a date attribute that is not a string, an unknown group, more than 20 steps or 5 waits, a trailing wait, delete before the last step, an email to that is not subject, manager, an active member's address or an address at a verified domain); INVALID_POLICY for a malformed scope condition; TEAM_MANAGED (409) for a group step naming a team's backing group; NOT_FOUND for an unknown package; CONFLICT (409) when a workflow has the name; LIMIT_EXCEEDED (409) past 100 workflows; ACCESS_DENIED without a step's permission or from a role session, session token, delegated session or impersonation; RECENT_AUTH_REQUIRED.

enabled defaults to true and includeExisting to false. maxRunsPerDay (1 to 10000) defaults to 25 when a step takes access away and 200 otherwise. Mover and leaver workflows take everyone's current values as their baseline, so only later changes fire. The result is the workflow with ownerId, ownerName, version 1 and activeSince.

await iam.api.workflows.create(admin, {
  tenantId,
  name: 'Leavers',
  trigger: { kind: 'leaver' },
  steps: [
    { kind: 'remove-from-all-groups' },
    { kind: 'revoke-packages' },
    { kind: 'wait', hours: 720 },
    { kind: 'delete' },
  ],
});
Input

Prop

Type

Returns

A WorkflowView object:

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/create" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "name": "<name>",
  "trigger": {
    "kind": "joiner"
  },
  "steps": [
    {
      "kind": "add-to-group",
      "groupId": "<groupId>"
    }
  ],
  "tenantId": "<tenantId>"
}'
Signature
iam.api.workflows.create(
  credential: CredentialInput,
  input: WorkflowInput & { tenantId: string },
): Promise<WorkflowView>

delete

Deletes a workflow and cancels its pending, running and waiting runs; finished runs stay as history.

POST/api/iam/workflows/delete
client.workflows.delete()Credential
  • Permission: iam:workflows:manage on the workflow.
  • Audited as: iam:workflows:manage.
  • Errors: NOT_FOUND when the workflow is not in this tenant.

The mover and leaver baselines go with it. The result is { deleted: true, runsCancelled }. To stop new runs but keep the workflow, disable it with update (enabled: false).

Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/delete" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "workflowId": "<workflowId>"
}'
Signature
iam.api.workflows.delete(
  credential: CredentialInput,
  input: { tenantId: string; workflowId: string },
): Promise<{ deleted: boolean; runsCancelled: number }>

evaluate

Evaluates this organization's workflows and runs whatever is due now, instead of waiting for the scheduler.

POST/api/iam/workflows/evaluate
client.workflows.evaluate()Credential
  • Permission: iam:workflows:run on the tenant.
  • Audited as: iam:workflows:run; the runs record workflow:* events as usual.

It does what iam.workflows.runDue({ tenantId }) does: starts the runs triggers call for (within the daily brake) and carries on pending runs, waits that have ended, and runs whose worker lapsed. The result is { started, executed, completed, failed, waiting }. Steps run with each run owner's rights, not the caller's.

Input

Prop

Type

Returns

A WorkflowJobResult object:

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/evaluate" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>"
}'
Signature
iam.api.workflows.evaluate(
  credential: CredentialInput,
  input: { tenantId: string },
): Promise<WorkflowJobResult>

get

Returns one workflow with run statistics and its 50 most recent runs.

POST/api/iam/workflows/get
client.workflows.get()Credential
  • Permission: iam:workflows:read on the workflow.
  • Audited as: iam:workflows:read.
  • Errors: NOT_FOUND when the workflow is not in this tenant.

runs counts runs started in the last 30 days (last30Days), those in progress (active) and failed, with lastRunAt. recentRuns lists runs newest first, with each person's identityName for callers who may read the directory. brakedOn is the day the daily brake last held runs back.

Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/get" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "workflowId": "<workflowId>"
}'
Signature
iam.api.workflows.get(
  credential: CredentialInput,
  input: { tenantId: string; workflowId: string },
): Promise<{
  recentRuns: WorkflowRunView[];
  id: string;
  tenantId: string;
  name: string;
  description?: string;
  enabled: boolean;
  trigger: WorkflowTrigger;
  scope?: AutoAssignInput;
  steps: WorkflowStep[];
  includeExisting: boolean;
  maxRunsPerDay: number;
  ownerId: string;
  ownerName?: string;
  version: number;
  activeSince: number;
  brakedOn?: string;
  createdAt: number;
  updatedAt: number;
  runs?: { last30Days: number; active: number; failed: number; lastRunAt?: number };
}>

getRun

Returns one run with its steps and results.

POST/api/iam/workflows/getRun
client.workflows.getRun()Credential
  • Permission: iam:workflows:read on the run.
  • Audited as: iam:workflows:read.
  • Errors: NOT_FOUND when the run is not in this tenant.

The run carries the steps it started with and workflowVersion, the occurrence that fired (joiner, mover:N:HASH, leaver:N, date:YYYY-MM-DD or manual:ID), stepIndex, nextAt while waiting, the results, startedBy for runs started or retried by hand, and error when it failed.

Input

Prop

Type

Returns

A WorkflowRunView object:

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/getRun" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "runId": "<runId>"
}'
Signature
iam.api.workflows.getRun(
  credential: CredentialInput,
  input: { tenantId: string; runId: string },
): Promise<WorkflowRunView>

list

Lists the tenant's workflows by name, with run statistics.

POST/api/iam/workflows/list
client.workflows.list()Credential
  • Permission: iam:workflows:read on the tenant.
  • Audited as: iam:workflows:read.

Each workflow carries its definition, ownerId (and ownerName for callers who may read the directory), and runs (last30Days, active, failed, lastRunAt).

Input

Prop

Type

Returns

An array of WorkflowView.

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/list" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>"
}'
Signature
iam.api.workflows.list(
  credential: CredentialInput,
  input: { tenantId: string },
): Promise<WorkflowView[]>

listRuns

Lists runs, newest first, optionally of one workflow, one person or one status.

POST/api/iam/workflows/listRuns
client.workflows.listRuns()Credential
  • Permission: iam:workflows:read on the tenant.
  • Audited as: iam:workflows:read.
  • Errors: INVALID_INPUT for a limit outside 1 to 500 or a malformed filter.

Filter by workflowId, identityId and status; page with limit (100 by default) and offset. The result is total and runs, each with identityName for callers who may read the directory. Finished runs are swept 180 days after they end.

Input

Prop

Type

Returns

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/listRuns" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>"
}'
Signature
iam.api.workflows.listRuns(
  credential: CredentialInput,
  input: {
    tenantId: string;
    workflowId?: string;
    identityId?: string;
    status?: WorkflowRunStatus;
    limit?: number;
    offset?: number;
  },
): Promise<{ total: number; runs: WorkflowRunView[] }>

preview

Shows what a workflow would do right now, without changing anything.

POST/api/iam/workflows/preview
client.workflows.preview()Credential
  • Permission: iam:workflows:read on the workflow.
  • Audited as: iam:workflows:read.
  • Errors: NOT_FOUND when the workflow is not in this tenant.

The result has inScope (active people the scope matches), wouldStart (people a run would start for at the next evaluation, with the occurrence, before the daily brake; empty while the workflow is disabled), upcoming (date workflows: people whose date falls within 30 days, with the time) and steps (whether the workflow's owner still holds each step's permission on the tenant, with a reason when not). People carry their names and emails only for callers who may read the directory; otherwise name is the ID.

const preview = await iam.api.workflows.preview(admin, { tenantId, workflowId });
const blocked = preview.steps.filter((step) => !step.allowed);
Input

Prop

Type

Returns

A WorkflowPreview object:

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/preview" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "workflowId": "<workflowId>"
}'
Signature
iam.api.workflows.preview(
  credential: CredentialInput,
  input: { tenantId: string; workflowId: string },
): Promise<WorkflowPreview>

retryRun

Resumes a failed run at the step that failed, with the caller's rights.

POST/api/iam/workflows/retryRun
client.workflows.retryRun()Credential
  • Permission: iam:workflows:run on the run, the permissions of the remaining steps over the person from a session or API key of the caller's own, and a recent sign-in when a remaining step takes access away.
  • Audited as: iam:workflows:run; the run records workflow:* events as usual.
  • Errors: INVALID_TRANSITION (409) when the run has not failed; ACCESS_DENIED without the remaining steps' permissions, or when a remaining step would change the caller's own account; RECENT_AUTH_REQUIRED; NOT_FOUND when the run is not in this tenant.

Retrying approves the remaining steps: the caller becomes the run's owner and startedBy, and the steps run with the caller's rights until the next wait before the call returns. Steps already done are not repeated. Fix what failed first, such as a group that was deleted or a legal hold, and retry runs that failed with OWNER_INACTIVE to take them over. The result is the run, listed by ID, which may have failed again.

Input

Prop

Type

Returns

A WorkflowRunView object:

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/retryRun" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "runId": "<runId>"
}'
Signature
iam.api.workflows.retryRun(
  credential: CredentialInput,
  input: { tenantId: string; runId: string },
): Promise<WorkflowRunView>

run

Runs a workflow now for up to 100 people you choose, whatever its trigger and scope.

POST/api/iam/workflows/run
client.workflows.run()Credential
  • Permission: iam:workflows:run on the workflow, the steps' permissions over each person, and a recent sign-in when a step takes access away.
  • Audited as: iam:workflows:run, plus workflow:run:start for each person with the caller as the actor.
  • Errors: INVALID_INPUT for an empty list, more than 100 people, or a service account or agent; NOT_FOUND for an unknown or deleted person; ACCESS_DENIED without a step's permission over a person, when the caller names themself for a workflow that would change their own account, or from a role session, session token, delegated session or impersonation; RECENT_AUTH_REQUIRED.

Steps run until the first wait before the call returns, with the workflow owner's rights (both the caller and the owner must be allowed). A step that fails fails that person's run, not the call. The result lists the runs by ID. Runs started this way neither count toward the daily brake nor are held by it, and disabled people may be named.

const [result] = await iam.api.workflows.run(admin, {
  tenantId,
  workflowId,
  identityIds: [identityId],
});
if (result.status === 'failed') console.warn(result.error);
Input

Prop

Type

Returns

An array of WorkflowRunView.

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/run" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "workflowId": "<workflowId>",
  "identityIds": [
    "<identityId>"
  ]
}'
Signature
iam.api.workflows.run(
  credential: CredentialInput,
  input: { tenantId: string; workflowId: string; identityIds: string[] },
): Promise<WorkflowRunView[]>

update

Edits a workflow; the caller becomes its owner and must hold the steps' permissions.

POST/api/iam/workflows/update
client.workflows.update()Credential
  • Permission: iam:workflows:manage on the workflow, plus each step's permission, and a recent sign-in when a step takes access away.
  • Audited as: iam:workflows:manage.
  • Errors: as for create, and INVALID_INPUT for an activeRuns other than keep or cancel; NOT_FOUND when the workflow is not in this tenant.

Fields you leave out keep their values; scope: null removes the scope, and description: null or '' clears the description. Changing the trigger, or enabling a disabled workflow, restarts it: joiners and dates count from now, and mover and leaver baselines are taken again. Runs already started keep their steps and the owner who approved them; activeRuns: 'cancel' cancels the workflow's pending, running, waiting and failed runs instead. Passing maxRunsPerDay clears brakedOn. Saving, even without changes, is how an administrator takes over a workflow whose owner left, for the runs that start from then on.

// Take the workflow over and raise the brake after checking what fired.
await iam.api.workflows.update(admin, { tenantId, workflowId, maxRunsPerDay: 100 });
Input

Prop

Type

Returns

A WorkflowView object:

Prop

Type

Example HTTP request

Only the required fields are shown; replace each <placeholder>. The response is { "data": … } on success or { "error": { "code", "message" } }.

curl -X POST "$IAM_URL/api/iam/workflows/update" \
  -H "Authorization: Bearer $BETTER_IAM_TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Better-IAM: 1" \
  -d '{
  "tenantId": "<tenantId>",
  "workflowId": "<workflowId>"
}'
Signature
iam.api.workflows.update(
  credential: CredentialInput,
  input: Partial<WorkflowInput> & {
    tenantId: string;
    workflowId: string;
    activeRuns?: 'keep' | 'cancel';
  },
): Promise<WorkflowView>

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page