Human approvals
A human_approval node pauses execution until someone (analyst, MLRO, ops lead) explicitly approves or rejects. The most common shape in compliance-heavy flows: "AML hit → wait for MLRO to clear → proceed."
In the workflow spec
{
"id": "wait_for_mlro",
"type": "human_approval",
"actionLabel": "AML hit on customer ${trigger.customerId} — MLRO review required",
"assignTo": "role:mlro",
"timeoutHours": 24,
"onTimeout": "route_to:notify_escalation",
"data": {
"customerName": "${step.kyc.output.customer.fullName}",
"screeningResults": "${step.screening.output.matches}"
}
}| Field | Notes |
|---|---|
actionLabel | Human-readable description that surfaces on the approver's queue. Templating allowed. |
assignTo | role:<role-name> · user:<userId> · team:<teamId>. Anyone in the assignment scope can act. |
timeoutHours | If no one acts within this window, onTimeout fires (or the execution fails). |
data | Extra payload surfaced to the approver UI — pass any context they need to decide. |
When the execution reaches this node, status becomes awaiting_approval and a workflow.human_approval_pending webhook fires. The approver acts via the dashboard or the API.
Approve via API
/api/executions/{id}/approve{
"decision": "approve | reject",
"notes": "Verified counterparty via voice call; OK to proceed."
}notes are required (min 20 chars) and persist on the step's audit row.
Response:
{
"data": {
"execution": {
"id": "exe_01HXY...",
"status": "running",
"steps": [ ..., { "id": "stp_...", "nodeId": "wait_for_mlro", "status": "succeeded", "output": { "decision": "approve", "notes": "...", "actorUserId": "usr_..." } } ]
}
}
}The execution resumes with the next downstream node. The decision is available to downstream nodes as ${step.wait_for_mlro.output.decision} — useful when both approve and reject branches need different routing:
{
"id": "route_after_approval",
"type": "decision",
"expression": "step.wait_for_mlro.output.decision",
"cases": {
"approve": { "next": "continue_onboarding" },
"reject": { "next": "notify_failure" }
}
}Reject
A rejection routes the same way as an approval — the difference is just the value in step.<id>.output.decision. Most workflows have a downstream decision node that routes accordingly.
If you don't want to handle rejection explicitly, configure onReject: fail on the human_approval node and the execution will fail-out cleanly with failureReason: "rejected by approver".
{
"id": "wait_for_mlro",
"type": "human_approval",
...,
"onReject": "fail | route_to:<nodeId>"
}Timeout behaviour
If no one acts within timeoutHours, one of:
onTimeout: "route_to:<nodeId>"— jump to a recovery node (escalate, notify ops, etc.).onTimeout: "fail"— execution fails withfailureReason: "approval timeout".
Default is fail. Most production workflows use route_to: so an escalation path is always defined.
Approver assignment
| Form | Meaning |
|---|---|
role:<role-name> | Anyone in the org with this role can act. E.g. role:mlro. |
user:<userId> | Specific person. Use when approval routing is deterministic. |
team:<teamId> | Anyone in the team. Use for round-robin manual queues. |
dynamic:<expression> | Evaluate at runtime: dynamic:step.shape.output.suggestedApproverId |
Audit guarantees
Every approval action writes a row to the immutable audit log:
actorUserId · timestamp · executionId · stepId · decision · notes · ip · userAgentThe audit log is enforced by Postgres triggers — even DB-admin queries can't silently rewrite history. Export quarterly for examiner audits via the dashboard's audit-log export.
Approvals don't pause downstream side effects retroactively
If a service_call step before the approval already wrote data to a sibling product (e.g. created a customer in Identity Platform), an approval reject doesn't undo that write. Model compensation explicitly: after a rejection, add a service_call to delete the customer (or whatever rollback is appropriate). Don't assume "reject = nothing happened."
Approver UX
Approvers act from the Orchestration dashboard's Approvals queue. The queue:
- Lists every
awaiting_approvalexecution they're assigned (via role / team / direct). - Shows the
actionLabel+datapayload. - Provides Approve + Reject buttons with a required notes field.
- Auto-refreshes — multiple approvers don't collide (first-action-wins).
For programmatic approvers (rare — usually CI bots or AI-Automation flows), the API endpoint is the same.