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": {}
}
}
| Field | Required | Description |
|---|---|---|
name | Yes | Human-readable flow name, unique within the tenant |
trigger | Yes | Flow 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. |
definition | Yes | Flow graph definition |
allowedClientIds | No | Client IDs allowed to start this flow. Empty or omitted means no client allowlist |
enabled | No | Defaults 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",
"onSuccess": "password",
"onFailure": "identify",
"properties": {
"authenticatorId": "identifier"
}
}
}
}
| Field | Required | Description |
|---|---|---|
start | Yes | ID of the first node to execute |
nodes | Yes | Object whose keys are node IDs and whose values are node definitions |
allowUnauthenticated | No | Whether 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, the control-flow fields that
type uses (onSuccess/onFailure for linear nodes, branches/else for
condition), properties for node-type-specific configuration such as
authenticatorId or maxAttempts, and an optional outputs map — every
node type accepts outputs, regardless of what's in properties. Keeping
configuration under properties means new node types add fields there
instead of growing the shared node shape. See Node outputs
for what outputs does.
{
"type": "authenticator",
"onSuccess": "allow",
"onFailure": "totp",
"properties": {
"authenticatorId": "<totp-authenticator-id>",
"maxAttempts": 5,
"onLockout": "deny"
}
}
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
| Type | Purpose |
|---|---|
authenticator | Resolves the user by their login identifier, or verifies built-in password or a configured TOTP authenticator |
condition | Evaluates CEL expressions and chooses the next node |
outcome | Completes the flow run |
flow | Jumps into another flow, resuming this one when it completes |
script | Runs tenant-authored Starlark logic in-process, no network/filesystem access |
webhook | Calls a tenant-supplied HTTP endpoint; its response becomes the step result |
credential | Grants a credential (currently: an OID4VCI credential offer) to the run's subject |
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",
"onSuccess": "password",
"onFailure": "identify",
"properties": {
"authenticatorId": "identifier"
}
}
Built-in password step:
{
"type": "authenticator",
"onSuccess": "allow",
"onFailure": "password",
"properties": {
"authenticatorId": "password",
"maxAttempts": 5,
"onLockout": "deny"
}
}
TOTP step, using the ready-to-use built-in instance (see below — no setup required):
{
"type": "authenticator",
"onSuccess": "allow",
"onFailure": "totp",
"properties": {
"authenticatorId": "totp",
"maxAttempts": 5,
"onLockout": "deny"
}
}
| Field | Required | Description |
|---|---|---|
type | Yes | Must be authenticator |
onSuccess | Yes | Node ID to execute after successful verification |
onFailure | No | Node ID to execute after failed verification. Often points back to the same node |
properties.authenticatorId | Yes | identifier to resolve the user without a credential, password for the built-in password step, or a configured authenticator's ID |
properties.maxAttempts | No | Number of failed attempts before properties.onLockout is used |
properties.onLockout | No | Node ID to execute after maxAttempts failures |
properties.authenticatorId is one of:
identifier— the built-in identifier steppassword— the built-in password steptotp— 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 viaPOST /authenticators, and can't be updated or deleted — so create a separatetotp-type instance instead if you need different settings (see Managing Authenticators)- the
idof a tenant-configuredAuthenticatorinstance — never an authenticator type name directly
To discover valid values:
- Configured instances (and their IDs):
GET /authenticators(o2idctl authenticators list) — always includes the built-intotpfirst, even before anything is explicitly created - Types available to configure:
GET /authenticators/meta(o2idctl authenticators list types) — todaytotp(a synchronous code-entry factor, see the "Configured TOTP step" example above) andverifiable_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 authenticatorId/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:
| Path | Description |
|---|---|
step.<nodeId>.result | success or failure |
step.<nodeId>.attempts | Failed attempt count |
step.<nodeId>.claims | An out-of-band authenticator's disclosed claims on success (e.g. a presented credential's claims) |
user.id | Resolved user ID — set by the identifier and password authenticators |
user.userType | The user's assigned user type key, e.g. employee — always set once resolved |
user.username | The value of whatever field the user's type designates as its login identifier |
user.enrolledFactors | Verified factor types for the user, such as totp |
user.attributes | User 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"
}
| Field | Required | Description |
|---|---|---|
type | Yes | Must be condition |
branches | Yes | Ordered list of branches |
branches[].if | Yes | CEL expression |
branches[].then | Yes | Node reference, or inline condition object |
else | Yes | Node 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:
| Path | Type | Description |
|---|---|---|
user.id | string | Resolved user ID |
user.userType | string | The user's assigned user type key, e.g. employee — always set |
user.username | string | The value of whatever field the user's type designates via usernameField |
user.enrolledFactors | list<string> | Verified factor types for the user, such as totp |
user.attributes | map | User attributes available to the run — email, when present, is always at user.attributes.email |
tenant.mfaRequired | bool | Tenant MFA policy flag. Defaults to false when no policy value is available |
step.<nodeId>.result | string | success or failure for a completed step |
step.<nodeId>.attempts | int | Failed attempt count recorded by that step |
step.<nodeId>.outputs.<name> | any | A named value declared by that node's outputs — see Node outputs |
risk.score | int | Risk 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
}
}
}
| Field | Required | Description |
|---|---|---|
type | Yes | Must be outcome |
properties.result | Yes | Result string, such as allow, deny, or success |
properties.claims | No | JSON 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"
}
}
| Field | Required | Description |
|---|---|---|
type | Yes | Must be flow |
onSuccess | Yes | Node ID to execute when the target flow's run completes with a non-deny result |
onFailure | No | Node 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.trigger | Yes | Trigger of the flow to jump into |
properties.nodeId | No | Node 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:
| Path | Description |
|---|---|
step.<nodeId>.result | success or failure, from the target flow's outcome result |
step.<nodeId>.claims | The 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
}
}
| Field | Required | Description |
|---|---|---|
type | Yes | Must be script |
onSuccess | Yes | Node ID to execute when the script sets a result |
onFailure | Yes | Node ID to execute when the script raises an exception, fails to set result, or exceeds its step/time limit |
properties.source | Yes | Starlark source. Must assign a top-level result global — that value becomes step.<nodeId>.result |
properties.maxSteps | No | Abstract computation step ceiling. Defaults to a server-side value; always clamped to a hard maximum regardless of what's configured |
properties.timeoutMs | No | Wall-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:
| Path | Description |
|---|---|
step.<nodeId>.result | The script's result value, whatever shape it is |
step.<nodeId>.error | Failure 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": "..." }
}
}
| Field | Required | Description |
|---|---|---|
type | Yes | Must be webhook |
onSuccess | Yes | Node ID to execute after a 2xx response with a valid JSON body |
onFailure | Yes | Node ID to execute on any other outcome — network error, non-2xx status, an oversized or malformed body |
properties.url | Yes | Absolute http:// or https:// URL |
properties.method | No | Defaults to POST |
properties.timeoutMs | No | Request timeout in milliseconds. Defaults to a server-side value; always clamped to a hard maximum |
properties.headers | No | Extra 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:
| Path | Description |
|---|---|
step.<nodeId>.result | The parsed JSON response body |
step.<nodeId>.error | Failure 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
}
}
| Field | Required | Description |
|---|---|---|
type | Yes | Must be credential |
onSuccess | Yes | Node ID to execute once the client acknowledges the credential offer |
onFailure | Yes | Node ID to execute on any failure — unknown credential kind, unknown or disabled verifiable credential, or no credential issuance configured on this server |
properties.kind | Yes | Which kind of credential to grant. Only verifiable_credential is implemented today |
properties.verifiableCredentialId | Yes, for kind verifiable_credential | The verifiable credential to issue against — must exist and be enabled |
properties.claimsFromContext | No, kind verifiable_credential only | Context 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.requireTxCode | No, kind verifiable_credential only | Requests 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:
| Path | Description |
|---|---|
step.<nodeId>.result | The OID4VCI credential_offer object — the same shape POST /verifiable-credential-offers returns |
step.<nodeId>.error | Failure reason, set instead of result when the node routed to onFailure |
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:
| Check | Description |
|---|---|
| Definition shape | start and nodes are present and valid JSON |
| Node references | Every referenced node ID exists |
| Authenticator IDs | Referenced configured authenticators exist |
| Flow references | A flow node's trigger resolves to an active flow, and its nodeId (if set) exists in that flow |
| CEL expressions | Condition expressions compile |
| Script syntax | script node source parses as valid Starlark (parsed, not executed) |
| Webhook URLs | webhook node properties.url is an absolute http(s) URL |
| Credential kind | credential node properties.kind is a recognized kind |
| Verifiable credential IDs | A verifiable_credential-kind credential node's properties.verifiableCredentialId is present |
| Reachability | Every node is reachable from start |
| Unsupported nodes | Node 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