Skip to main content
Version: Next

Flow Definition Schema

This reference describes the JSON shape accepted by O2ID flow definitions. For a usage-focused introduction, see Flows.

Flow resource

A flow resource wraps a definition with tenant metadata:

{
"name": "Login",
"trigger": "login",
"enabled": true,
"allowedClientIds": [],
"definition": {
"start": "identify",
"nodes": {}
}
}
FieldRequiredDescription
nameYesHuman-readable flow name, unique within the tenant
triggerYesFlow trigger name. Use reserved system trigger values such as login for O2ID-invoked journeys; use names such as step-up or age-verification for custom journeys.
definitionYesFlow graph definition
allowedClientIdsNoClient IDs allowed to start this flow. Empty or omitted means no client allowlist
enabledNoDefaults to true

Definition object

The definition object has two required fields, plus one optional field that controls whether the flow may be started with no authenticated caller:

{
"start": "identify",
"nodes": {
"identify": {
"type": "authenticator",
"id": "identifier",
"onSuccess": "password",
"onFailure": "identify"
}
}
}
FieldRequiredDescription
startYesID of the first node to execute
nodesYesObject whose keys are node IDs and whose values are node definitions
allowUnauthenticatedNoWhether this flow may be started with no known subject. Omit to use the default: true only for the login trigger, false for every other trigger. Set explicitly to opt any trigger in or out

allowUnauthenticated is what lets /oauth2/authorize's ?prompt= param (see Flows) launch a trigger other than login for an unauthenticated user — the trigger name itself carries no special meaning; only this flag, and whether the caller already has a subject, do.

Node IDs are local to the flow definition. They are referenced by fields such as onSuccess, onFailure, properties.onLockout, condition branch then, and condition else.

Node shape

Every node has the same top-level shape: type, an optional id naming the resource this node configures (an authenticator ID for authenticator, a connection ID for connection — left unset for node types with no such resource), the control-flow fields that type uses (onSuccess/onFailure for linear nodes, branches/else for condition), properties for anything else node-type-specific, and an optional outputs map — every node type accepts outputs, regardless of what's in properties. Keeping configuration under properties (or, for the one resource reference a node type has, under the shared id field) means new node types add fields there instead of growing the shared node shape. See Node outputs for what outputs does.

{
"type": "authenticator",
"id": "<totp-authenticator-id>",
"onSuccess": "allow",
"onFailure": "totp",
"properties": {
"maxAttempts": 5,
"onLockout": "deny"
}
}

id also doubles as the selection key when this node appears inline inside a choice node's options array (see choice) — there is no separate "option key" concept, an option is just a node.

Node references

Most transitions are plain string references:

{
"onSuccess": "allow",
"onFailure": "password"
}

Condition branch then and condition else also support inline condition objects:

{
"type": "condition",
"branches": [
{
"if": "'totp' in user.enrolledFactors",
"then": "totp"
}
],
"else": {
"type": "condition",
"branches": [
{
"if": "tenant.mfaRequired",
"then": "totp"
}
],
"else": "allow"
}
}

authenticator.onSuccess, authenticator.onFailure, and authenticator.properties.onLockout are string node IDs.

Supported node types

TypePurpose
authenticatorResolves the user by their login identifier, or verifies built-in password or a configured TOTP authenticator
conditionEvaluates CEL expressions and chooses the next node
outcomeCompletes the flow run
flowJumps into another flow, resuming this one when it completes
scriptRuns tenant-authored Starlark logic in-process, no network/filesystem access
webhookCalls a tenant-supplied HTTP endpoint; its response becomes the step result
connection_callCalls an operation on a registered connection (e.g. a remote connection type); its response becomes the step result
credentialGrants a credential (currently: an OID4VCI credential offer) to the run's subject
choicePresents several sign-in options at once; the client picks which one runs
organization_contextDetects a nested Organization tenant and shows a "signing in to" banner naming it

Use metadata to discover the node types accepted by the running O2ID instance.

authenticator

An authenticator node resolves the user's identifier, or verifies a credential or factor.

Built-in identifier step — resolves a user without verifying a credential. Useful when a later node needs to act on the resolved user (branch on user.enrolledFactors, tenant policy, etc.) before deciding what to verify next:

{
"type": "authenticator",
"id": "identifier",
"onSuccess": "password",
"onFailure": "identify"
}

Built-in password step:

{
"type": "authenticator",
"id": "password",
"onSuccess": "allow",
"onFailure": "password",
"properties": {
"maxAttempts": 5,
"onLockout": "deny"
}
}

TOTP step, using the ready-to-use built-in instance (see below — no setup required):

{
"type": "authenticator",
"id": "totp",
"onSuccess": "allow",
"onFailure": "totp",
"properties": {
"maxAttempts": 5,
"onLockout": "deny"
}
}
FieldRequiredDescription
typeYesMust be authenticator
idYesidentifier to resolve the user without a credential, password for the built-in password step, or a configured authenticator's ID
onSuccessYesNode ID to execute after successful verification
onFailureNoNode ID to execute after failed verification. Often points back to the same node
properties.maxAttemptsNoNumber of failed attempts before properties.onLockout is used
properties.onLockoutNoNode ID to execute after maxAttempts failures

id is one of:

  • identifier — the built-in identifier step
  • password — the built-in password step
  • totp — a ready-to-use, zero-config TOTP factor every tenant gets for free (default issuer "O2ID", 6 digits, 30-second period). It's virtual — never created via POST /authenticators, and can't be updated or deleted — so create a separate totp-type instance instead if you need different settings (see Managing Authenticators)
  • the id of a tenant-configured Authenticator instance — never an authenticator type name directly

To discover valid values:

  • Configured instances (and their IDs): GET /authenticators (o2idctl authenticators list) — always includes the built-in totp first, even before anything is explicitly created
  • Types available to configure: GET /authenticators/meta (o2idctl authenticators list types) — today totp (a synchronous code-entry factor, see the "Configured TOTP step" example above) and verifiable_credential_presentation (an out-of-band factor — see Out-of-band verification below)

The built-in password step doubles as an identify step when it's reached with no user already resolved (i.e. no identifier authenticator ran before it in this run) — it accepts identifier (or username) alongside password in the same submission and resolves the user itself, instead of requiring a separate identifier step first. This is what O2ID's implicit default login flow uses, so a plain login renders as one combined form rather than two screens. nodeView.fields reflects which shape is expected: ["identifier", "password"] when combined, or just ["password"] when an earlier identifier authenticator already resolved the user. Use a separate identifier authenticator instead when you need to act on the resolved user (e.g. branch on user.enrolledFactors) before verifying the password.

Expected identifier submission — POST /flow-runs/{id}/submit:

{ "values": { "identifier": "alice.chen" } }

Submit the value of whatever field the user's type designates as its login identifier — username for the built-in user type, or whatever a custom type's usernameField names.

Expected password submission:

{ "values": { "password": "secret" } }

Or, standing alone with no prior identifier authenticator:

{ "values": { "identifier": "alice.chen", "password": "secret" } }

Expected TOTP submission:

{ "values": { "code": "123456" } }

If the referenced TOTP authenticator has progressiveEnrollment enabled (see TOTP authenticator) and the subject has no verified enrollment yet, the node enrolls them in place instead of failing the code. First submit an empty values object to get setup material:

{ "values": {} }

nodeView.data then contains otpauthUrl and secret, and nodeView.fields becomes ["enrollmentId", "code"]. After the user scans the setup URI, submit both:

{ "values": { "enrollmentId": "<enrollment-id>", "code": "123456" } }

Out-of-band verification (e.g. credential presentation)

Some authenticator types verify asynchronously instead of via a client-submitted code — today, one such type: verifiable_credential_presentation (see Verifying Credentials), which asks the subject to present a Verifiable Credential from their wallet (OID4VP). Referencing one of these from an authenticator node works exactly like referencing TOTP — same id/maxAttempts/onLockout fields — but the node pauses instead of rendering fields: nodeView.data.requestUri carries an openid4vp:// deep link to render as a QR code, and nodeView.fields is absent (there's nothing to submit). The client should never call POST /flow-runs/{id}/submit on this node — only the authenticator's own out-of-band callback (POST /oid4vp/response) resumes it, so the portal polls GET /flow-runs/{id} instead until the view changes.

Context written:

PathDescription
step.<nodeId>.resultsuccess or failure
step.<nodeId>.attemptsFailed attempt count
step.<nodeId>.claimsAn out-of-band authenticator's disclosed claims on success (e.g. a presented credential's claims)
user.idResolved user ID — set by the identifier and password authenticators
user.userTypeThe user's assigned user type key, e.g. employee — always set once resolved
user.usernameThe value of whatever field the user's type designates as its login identifier
user.enrolledFactorsVerified factor types for the user, such as totp
user.attributesUser attributes available to the run — email, when present, is always at user.attributes.email

condition

A condition node evaluates Google Common Expression Language (CEL) expressions in order. The first branch whose if expression evaluates to true wins. If no branch matches, else is used.

{
"type": "condition",
"branches": [
{
"if": "'totp' in user.enrolledFactors",
"then": "totp"
},
{
"if": "tenant.mfaRequired",
"then": "totp"
}
],
"else": "allow"
}
FieldRequiredDescription
typeYesMust be condition
branchesYesOrdered list of branches
branches[].ifYesCEL expression
branches[].thenYesNode reference, or inline condition object
elseYesNode reference, or inline condition object

Clients do not receive a nodeView for a condition node. O2ID evaluates the condition and advances the run to the selected next node.

Condition context

Conditions can read these context variables:

PathTypeDescription
user.idstringResolved user ID
user.userTypestringThe user's assigned user type key, e.g. employee — always set
user.usernamestringThe value of whatever field the user's type designates via usernameField
user.enrolledFactorslist<string>Verified factor types for the user, such as totp
user.attributesmapUser attributes available to the run — email, when present, is always at user.attributes.email
tenant.mfaRequiredboolTenant MFA policy flag. Defaults to false when no policy value is available
step.<nodeId>.resultstringsuccess or failure for a completed step
step.<nodeId>.attemptsintFailed attempt count recorded by that step
step.<nodeId>.outputs.<name>anyA named value declared by that node's outputs — see Node outputs
risk.scoreintRisk score. Defaults to 0 when no risk signal is available

Example expressions:

'totp' in user.enrolledFactors
step.password.result == 'success' && step.password.attempts == 0
tenant.mfaRequired || risk.score > 50

outcome

An outcome node completes the run.

Allow or deny:

{
"type": "outcome",
"properties": {
"result": "allow"
}
}

Claims-producing result:

{
"type": "outcome",
"properties": {
"result": "success",
"claims": {
"ageOver18": true
}
}
}
FieldRequiredDescription
typeYesMust be outcome
properties.resultYesResult string, such as allow, deny, or success
properties.claimsNoJSON object returned with the outcome

deny marks the flow run as failed. Other result values complete the run.

flow

A flow node jumps into another flow, identified by its trigger, and resumes this flow's onSuccess/onFailure when that flow's run reaches an outcome node — the same way onSuccess/onFailure resume after any other node type, just with an entire other flow run in between.

The target flow is resolved exactly like POST /flow-runs resolves its own trigger: the active, enabled flow for that trigger, falling back to the built-in login flow for login when no custom one is active, or a not-found error otherwise. It starts at that flow's own start node unless properties.nodeId names a different node in it.

{
"type": "flow",
"onSuccess": "allow",
"onFailure": "deny",
"properties": {
"trigger": "step-up",
"nodeId": "totp"
}
}
FieldRequiredDescription
typeYesMust be flow
onSuccessYesNode ID to execute when the target flow's run completes with a non-deny result
onFailureNoNode ID to execute when it completes with deny. If omitted, a deny ends this run entirely, exactly as if this had been a top-level outcome node
properties.triggerYesTrigger of the flow to jump into
properties.nodeIdNoNode ID within the target flow's definition to start at, instead of its start

Clients do not receive a nodeView for a flow node itself — the run keeps rendering whatever node the target flow lands on next, same as condition.

Context written:

PathDescription
step.<nodeId>.resultsuccess or failure, from the target flow's outcome result
step.<nodeId>.claimsThe target flow's outcome claims, if it set any

Nesting is capped at 10 levels deep — a flow node whose trigger (directly or transitively) leads back to itself fails with a recursion-limit error once the cap is hit, since two flows referencing each other's triggers can't be caught at authoring time (each would need the other to already be active to validate).

script

A script node runs tenant-authored Starlark source in-process. Starlark has no network or filesystem access by default, so a script node can't reach anything outside the flow run's own context — unlike condition's CEL expressions, Starlark is Turing-complete, so properties.maxSteps and properties.timeoutMs bound how much it can run.

{
"type": "script",
"onSuccess": "allow",
"onFailure": "deny",
"properties": {
"source": "result = {'riskAdjusted': risk['score'] > 50}",
"maxSteps": 100000,
"timeoutMs": 2000
}
}
FieldRequiredDescription
typeYesMust be script
onSuccessYesNode ID to execute when the script sets a result
onFailureYesNode ID to execute when the script raises an exception, fails to set result, or exceeds its step/time limit
properties.sourceYesStarlark source. Must assign a top-level result global — that value becomes step.<nodeId>.result
properties.maxStepsNoAbstract computation step ceiling. Defaults to a server-side value; always clamped to a hard maximum regardless of what's configured
properties.timeoutMsNoWall-clock ceiling in milliseconds. Same defaulting/clamping as maxSteps

Unlike Starlark's default BUILD-file dialect, if/for/while are allowed at the top level and reassigning a top-level name (total = 0 then later total = total + 1) is allowed too — a script node's source is a short imperative program, not a config file being loaded as a module, so it reads like an ordinary function body without needing to be wrapped in a def.

The script reads the same user/tenant/step/risk values condition nodes evaluate against (see Condition context) as predeclared globals, and nothing else — no per-node-type secret smuggling.

Clients do not receive a nodeView for a script node — like condition, it runs synchronously and the run keeps rendering whatever node comes next.

Context written:

PathDescription
step.<nodeId>.resultThe script's result value, whatever shape it is
step.<nodeId>.errorFailure reason, set instead of result when the node routed to onFailure

webhook

A webhook node calls a tenant-supplied HTTP endpoint and uses its response as the step result.

{
"type": "webhook",
"onSuccess": "allow",
"onFailure": "deny",
"properties": {
"url": "https://verify.example.com/check",
"method": "POST",
"timeoutMs": 5000,
"headers": { "X-Api-Key": "..." }
}
}
FieldRequiredDescription
typeYesMust be webhook
onSuccessYesNode ID to execute after a 2xx response with a valid JSON body
onFailureYesNode ID to execute on any other outcome — network error, non-2xx status, an oversized or malformed body
properties.urlYesAbsolute http:// or https:// URL
properties.methodNoDefaults to POST
properties.timeoutMsNoRequest timeout in milliseconds. Defaults to a server-side value; always clamped to a hard maximum
properties.headersNoExtra request headers

The request body is the same user/tenant/step/risk context CEL conditions and script nodes read, JSON-encoded. Every request carries an X-O2ID-Signature header — an HMAC-SHA256 of the request body under a key derived from the tenant, so the receiver can verify the call actually came from this tenant's flow run. The response body is size-capped (64KB) and must be JSON.

SSRF guard: by default, a webhook node cannot reach a private/loopback/link-local/unspecified/multicast address, no matter what properties.url says — this blocks a flow author from using a webhook to probe or call internal infrastructure. To call an internal endpoint deliberately, add its hostname to the tenant's webhook allowlist:

o2idctl tenant-settings update --webhook-allowlist internal-service.example.internal

--webhook-allowlist is repeatable — pass it once per host to allow more than one. --clear-webhook-allowlist removes every host. Equivalently, PATCH /settings accepts the same field directly:

PATCH /settings
{ "webhookAllowlist": ["internal-service.example.internal"] }

An allowlisted hostname is exempted from the address check entirely — only add hosts the tenant genuinely intends flows to reach.

Clients do not receive a nodeView for a webhook node — like condition, it runs synchronously and the run keeps rendering whatever node comes next.

Context written:

PathDescription
step.<nodeId>.resultThe parsed JSON response body
step.<nodeId>.errorFailure reason, set instead of result when the node routed to onFailure

connection_call

A connection_call node calls an operation on a connection and uses its response as the step result — the generic counterpart to connection's redirect-based federated login. Unlike connection, it works with any connection whose type declares the operation it names, not only an identity-provider one: send an SMS, verify a document, look up a value in an external system, and so on. See Connection Types for how a connection gets an operation to call in the first place.

{
"type": "connection_call",
"id": "acme-idv-connection",
"onSuccess": "allow",
"onFailure": "deny",
"properties": {
"operation": "verify",
"input": { "documentId": "step.upload.outputs.documentId" },
"timeoutMs": 8000
}
}
FieldRequiredDescription
typeYesMust be connection_call
idYesID of a connection whose type declares properties.operation among its operations
onSuccessYesNode ID to execute after a successful call
onFailureYesNode ID to execute on any other outcome — network error, an unresolvable connection, an operation the connection's type doesn't declare, or the connector's own reported failure
properties.operationYesOperation name, as declared by the connection's type (GET /connections/meta, or o2idctl connections types)
properties.inputNoMap of input key to a CEL expression, evaluated in the same user/tenant/step/risk environment as a condition node's branches
properties.timeoutMsNoRequest timeout in milliseconds. Defaults to a server-side value; always clamped to a hard maximum

Clients do not receive a nodeView for a connection_call node — like webhook, it runs synchronously and the run keeps rendering whatever node comes next.

Context written:

PathDescription
step.<nodeId>.resultThe operation's JSON-object output
step.<nodeId>.errorFailure reason, set instead of result when the node routed to onFailure

credential

A credential node grants a credential to the run's current subject as a side effect of the flow — e.g. an age-verification or step-up sequence — instead of requiring a separate API call after the fact. properties.kind selects which kind of credential is granted and which other properties apply.

verifiable_credential is the only kind implemented today: it creates an OID4VCI credential offer against a verifiable credential you've already defined, instead of requiring a separate POST /verifiable-credential-offers call.

{
"type": "credential",
"onSuccess": "allow",
"onFailure": "deny",
"properties": {
"kind": "verifiable_credential",
"verifiableCredentialId": "verifiable-credential-id",
"claimsFromContext": ["ageOver18"],
"requireTxCode": false
}
}
FieldRequiredDescription
typeYesMust be credential
onSuccessYesNode ID to execute once the client acknowledges the credential offer
onFailureYesNode ID to execute on any failure — unknown credential kind, unknown or disabled verifiable credential, or no credential issuance configured on this server
properties.kindYesWhich kind of credential to grant. Only verifiable_credential is implemented today
properties.verifiableCredentialIdYes, for kind verifiable_credentialThe verifiable credential to issue against — must exist and be enabled
properties.claimsFromContextNo, kind verifiable_credential onlyContext keys to lift into the offer's claims (e.g. a value an earlier script/webhook/outcome step wrote to context). A name with no matching key in context is silently omitted, not an error — the same graph can be reused across scenarios where not every claim is always populated
properties.requireTxCodeNo, kind verifiable_credential onlyRequests a short numeric PIN alongside the QR code — see Requiring a tx_code PIN

Every claim name in claimsFromContext that does resolve to a value must still be one of the verifiable credential's own defined claim names — the exact same subset-of-schema validation POST /verifiable-credential-offers applies.

Unlike webhook/script, a credential node pauses on success: the run parks on this node and the client receives a nodeView of type credential whose data is the OID4VCI credential_offer object itself (credential_issuer, credential_configuration_ids, grants, credential_offer_uri), plus data.deepLinkUri (the openid-credential-offer:// URI to render as a QR code) and data.txCode (the raw PIN, only present when requireTxCode was set) for the portal to display. The client then submits an empty Advance to continue to onSuccess. A failure never pauses — it routes straight to onFailure, the same as webhook.

Context written:

PathDescription
step.<nodeId>.resultThe OID4VCI credential_offer object — the same shape POST /verifiable-credential-offers returns
step.<nodeId>.errorFailure reason, set instead of result when the node routed to onFailure

choice

A choice node presents several sign-in options at once and lets the client pick which one runs, instead of O2ID deciding via a condition node's server-evaluated CEL branches. Each entry in properties.options is a full node — its own id/type/properties/onSuccess/onFailure — not a separate wrapper shape, since an option is simply a node with no name of its own in the top-level nodes map.

{
"type": "choice",
"properties": {
"options": [
{ "id": "password", "type": "authenticator", "onSuccess": "allow" },
{ "id": "<connection-id>", "type": "connection", "onSuccess": "allow", "onFailure": "deny" },
{ "id": "org_sso", "type": "organization_context", "onSuccess": "allow" }
]
}
}
FieldRequiredDescription
typeYesMust be choice
properties.optionsYesAt least one inline node, each with a unique id. Supported option types: authenticator, connection, organization_context

A choice node itself has no onSuccess/onFailure — control flow comes entirely from whichever option's own onSuccess/onFailure runs. An option's onSuccess/onFailure are node IDs in the outer nodes map, exactly like a top-level node's — an option is not a nested sub-graph.

The nodeView for a choice node carries only id/type per option — no display label:

{
"type": "choice",
"data": {
"options": [
{ "id": "password", "type": "authenticator" },
{ "id": "org_sso", "type": "organization_context" }
]
}
}

Deciding what each option looks like (button text, icon) is the client's responsibility, keyed off id/type — the flow definition never authors a label. Submit which option was picked to advance:

{ "values": { "choice": "password" } }

organization_context

An organization_context node detects whether the currently-running flow was reached via a nested Organization's own path (see Organizations) and, if so, surfaces a "signing in to" banner naming it before continuing. Normally used as one option inside a choice node rather than as a standalone entry node. On a run not reached via an Organization path, it's a pure pass-through: it succeeds immediately with no pause at all, as if the node weren't there.

{
"type": "organization_context",
"onSuccess": "allow",
"properties": {
"mode": "banner"
}
}
FieldRequiredDescription
typeYesMust be organization_context
onSuccessYesNode ID to execute once the banner is acknowledged (or immediately, on a pass-through run)
properties.modeNobanner (default) shows the "signing in to" step naming the Organization. redirect/auto are reserved for a future release — validation rejects them today

When it pauses, nodeView.data carries the Organization's display info:

{
"type": "organization_context",
"data": {
"organizationName": "Acme, Inc.",
"organizationSlug": "acme"
}
}

The client submits an empty Advance to continue, the same idiom credential nodes use for their own zero-input continue step.

Node outputs

Any node can declare outputs: a map of name to CEL expression, evaluated in the same context as a condition's if (see Condition context above), plus this node's own step.<nodeId> entry, once that node completes. A later node reads the computed value at step.<nodeId>.outputs.<name>.

{
"type": "webhook",
"onSuccess": "check_risk",
"onFailure": "deny",
"properties": {
"url": "https://risk.example.com/score"
},
"outputs": {
"highRisk": "step.risk_check.result.score > 70"
}
}
step.risk_check.outputs.highRisk

Outputs are computed only on a node's success path — a failed node's step.<nodeId> entry has error instead of result/claims, so an expression written against the success shape has nothing to read. An empty or invalid expression fails flow validation at /nodes/<id>/outputs/<name>.

Validation

o2idctl flows validate <flow-id> checks:

CheckDescription
Definition shapestart and nodes are present and valid JSON
Node referencesEvery referenced node ID exists
Authenticator IDsReferenced configured authenticators exist
Flow referencesA flow node's trigger resolves to an active flow, and its nodeId (if set) exists in that flow
CEL expressionsCondition expressions compile
Script syntaxscript node source parses as valid Starlark (parsed, not executed)
Webhook URLswebhook node properties.url is an absolute http(s) URL
Connection call operationsconnection_call node properties.operation is declared by the referenced connection's type
Credential kindcredential node properties.kind is a recognized kind
Verifiable credential IDsA verifiable_credential-kind credential node's properties.verifiableCredentialId is present
ReachabilityEvery node is reachable from start
Unsupported nodesNode types not listed by metadata are rejected

Validation errors include a JSON Pointer so tooling can identify the exact field that failed:

{
"valid": false,
"errors": [
{
"pointer": "/nodes/mfa_check/branches/0/if",
"message": "ERROR: ..."
}
]
}

Metadata

Use metadata to discover the node types and context variables supported by the running O2ID instance:

o2idctl flows meta

The API also exposes a downloadable schema document:

GET /flows/meta/schema.json