Human Review
What a run parked on a person looks like from the outside, and how it resumes.
A workflow can stop mid-run and wait for a person. While it waits, the run's status is awaiting_approval — a real, observable state your integration will see, and one you should expect on any workflow whose graph contains a Human Review node.
There are two ways a run ends up there, and they behave differently.
1. A Human Review node
A step you place in the graph yourself. When the run reaches it, Elizon opens a review request, notifies the assignees, and the run sleeps.
Configuration
The Human Review node (human_review) is configured in Studio with:
| Field | Type | Default | Description |
|---|---|---|---|
title | string | '' | What the reviewer sees as the request's title. May contain {{ }} references. |
message | string | '' | The request's body. May contain {{ }} references. |
assignedTo | string | '' | Who reviews: email addresses, {{ }} expressions, or a mix, comma-separated — for example {{ upstream.lookup.output.result.accountOwnerEmail }}. Resolved when the step runs (see below). |
fallbackAssignedTo | string | '' | Literal email addresses used when none of the resolved approvers can review. |
responseFields | list of { name, type, default? } | [] | What the reviewer sees and can edit — each text, long_text, html, yes_no, email, number, or date. default is { value, valueMode: fixed | expression }, pre-filled for the reviewer. |
timeoutValue | integer ≥ 1 | 24 | How long to wait, in timeoutUnit. Between 1 hour and 30 days in total. |
timeoutUnit | hours | days | hours | The unit of timeoutValue. |
onTimeout | expire | approve | reject | expire | What happens when nobody answers in time (see Timeouts). |
mode | single | batch | single | batch reviews a list of items in one request — see Batch review. |
itemsExpression | string | '' | Batch only. An expression that resolves to a list of objects, e.g. {{ upstream.drafts.output.rows }}. |
itemLabel | string | '' | Batch only. Each item's title, an expression over item, e.g. {{ item.customerName }} — {{ item.invoiceNumber }}. Blank shows "Item 1", "Item 2", … |
itemFields | list of { name, type, default? } | [] | Batch only. The editable fields per item, with the same types as responseFields. A default may read the item: {{ item.draftBody }}. |
Who gets the request
assignedTo is resolved when the run reaches the node, then split on commas, trimmed, lower-cased and de-duplicated. An address is kept only if it belongs to a member of the run's organization who holds the workflow-review permission; anything else is dropped and listed in the node's droppedApprovers output, so you can see why someone got no email. A typo in one address does not block the review as long as one approver remains.
When every resolved address is dropped, fallbackAssignedTo is used. When the fallback is empty too, the step fails with a message naming the expression and what it resolved to.
Pre-filled, editable fields
Give a response field a default and the reviewer sees it filled in — a drafted email body, say — and can change it before approving. An expression default is resolved when the step starts. long_text is a multi-line text box; html is edited as source and shown as a preview that runs no scripts. Whatever the reviewer submits, edited or not, becomes output.response.
Output
The reviewer answers with Approve or Reject, and the node's output records the decision and the values:
{
"outcome": "responded",
"decision": "approved",
"resolvedBy": "reviewer",
"requestId": "6c1d0b7a-...",
"response": { "body": "Dear customer, ...", "notes": "Margin checks out." },
"responderEmail": "reviewer@example.com",
"respondedAt": "2026-09-01T09:14:22.104Z",
"timeoutAt": "2026-09-02T08:00:00.000Z",
"droppedApprovers": []
}| Field | Type | Description |
|---|---|---|
outcome | string | responded, or timed_out when the deadline passed first. |
decision | string | approved or rejected. |
resolvedBy | string | reviewer, or timeout when onTimeout decided. |
requestId | string | The review request's id. |
timeoutAt | string | When the request times out. |
responderEmail | string | null | Who answered (null on a timeout). |
respondedAt | string | null | When they answered (null on a timeout). |
droppedApprovers | string[] | Resolved approvers that were skipped (not a member with the review permission). |
The submitted values sit under response, so a downstream node reads one as {{ upstream.<nodeId>.output.response.body }} — not {{ upstream.<nodeId>.output.body }}, which resolves to nothing and fails the node.
A Reject is a value, not an error. The node completes either way, so branch with an If/Else on {{ upstream.<nodeId>.output.decision }} — for example "rejected → tell the account owner".
- First response wins. If several assignees are listed, the first submission resolves the request; a later one is refused.
Timeouts
onTimeout decides what happens when nobody answers before the deadline:
onTimeout | The node | The run |
|---|---|---|
expire (default) | Ends in error (outcome: timed_out) with a message naming the elapsed window. Everything downstream is unreachable. | Ends as failed. |
approve | Completes with decision: approved, resolvedBy: timeout, and the pre-filled defaults as response (null for a field with no default). "Send automatically after 24 hours unless someone stops it." | Carries on. |
reject | Completes with decision: rejected, resolvedBy: timeout. | Carries on. |
The review request records the timeout's decision too, so the audit trail and the inbox show it.
Batch review
When a run drafts many things to approve at once — every overdue invoice's reminder, say — set mode: batch. The reviewer gets one request listing every item, approves, edits or rejects each one, and submits once.
itemsExpressionmust resolve to a list of objects. Each item is shown with itsitemLabeland itsitemFields, pre-filled from the item:{{ item.… }}reads the item being shown. It works only initemLabeland in the item fields' defaults; anywhere else,itemis an unknown reference.- The items are snapshotted when the step starts: the reviewer sees, and the audit trail records, exactly what the run resolved.
- At most 200 items (a deployment can change this with
HUMAN_REVIEW_BATCH_MAX_ITEMS), and the items plus their pre-filled values at most 1 MB. Over either limit the step fails with a message naming the limit; split the list with a Loop node so each review gets a slice. - An empty list completes the step at once with empty output:
outcome: empty, empty lists, zero counts,resolvedBy: nullanddecision: rejected(no item was approved). No request is created and nobody is emailed. An If/Else ondecisiontherefore takes its rejected branch; checkcounts.approvedoroutcomewhen an empty morning must be told apart from a rejected one. - In the review inbox a batch shows its item count and opens its own screen: every item with its fields and its own Approve / Reject, plus Approve all, Reject all and Approve the rest, and running counts ("38 approved · 2 rejected · 4 to go"). Every item needs a decision before the review can be submitted. Edits stay in the browser until then — reloading the page loses them.
A batch's output lists the items by decision:
{
"outcome": "responded",
"decision": "approved",
"resolvedBy": "reviewer",
"approved": [
{
"index": 0,
"item": { "customerName": "Acme", "email": "ap@acme.example" },
"response": { "body": "Dear Acme, ..." }
}
],
"rejected": [{ "index": 1, "item": { "customerName": "Beta", "email": "ap@beta.example" } }],
"undecided": [],
"counts": { "approved": 1, "rejected": 1, "undecided": 0 },
"requestId": "6c1d0b7a-...",
"responderEmail": "controller@example.com",
"respondedAt": "2026-10-12T08:14:22.104Z",
"timeoutAt": "2026-10-13T08:00:00.000Z",
"droppedApprovers": []
}item is the original item; response is the reviewer's values for it, edited or not. decision is approved when at least one item was approved, otherwise rejected. On a timeout, onTimeout applies to every item at once (there is no partial save): approve approves each with its own pre-filled values, reject rejects each, expire fails the step.
Worked example: send only the approved reminders
Loop over approved, and each iteration has the original item and the reviewer's edits:
name: Morning reminders
description: One review of the morning's drafted reminders; sends only the approved ones.
graph:
nodes:
- id: review
type: human_review
position: { x: 0, y: 0 }
data:
title: Overdue reminders
message: Approve, edit or reject each reminder.
mode: batch
itemsExpression: '{{ upstream.drafts.output.rows }}'
itemLabel: '{{ item.customerName }} — {{ item.invoiceNumber }}'
itemFields:
- {
name: body,
type: long_text,
default: { value: '{{ item.draftBody }}', valueMode: expression },
}
timeoutValue: 24
timeoutUnit: hours
onTimeout: approve
approverRequired: true
- id: each
type: loop
position: { x: 280, y: 0 }
data:
sourceArray: '{{ upstream.review.output.approved }}'
- id: send
type: send_email
position: { x: 560, y: 0 }
data:
to: '{{ loop.item.item.email }}'
subject: 'Invoice {{ loop.item.item.invoiceNumber }}'
body: '{{ loop.item.response.body }}'
edges:
- { id: review-to-each, source: review, target: each }
- { id: each-to-send, source: each, sourceHandle: loop, target: send }A rejected reminder never reaches the Loop, and an edited one is sent as edited.
Example
name: Approve refund
description: Asks finance to approve a refund before it is paid.
graph:
nodes:
- id: refund-request
type: trigger_http
position: { x: 0, y: 0 }
data:
method: POST
fields:
- { name: amount, type: number, required: true }
- id: finance-approval
type: human_review
position: { x: 280, y: 0 }
data:
title: 'Refund of {{ trigger.body.amount }}'
message: Approve or reject this refund.
responseFields:
- { name: notes, type: long_text, default: { value: 'Looks fine.', valueMode: fixed } }
timeoutValue: 2
timeoutUnit: days
onTimeout: reject
approverRequired: true
- id: decide
type: if_else
position: { x: 560, y: 0 }
data:
branches:
- {
id: approved,
label: Approved,
expression: '{{ upstream.finance-approval.output.decision }} == approved',
}
defaultBranchId: default
edges:
- { id: request-to-review, source: refund-request, target: finance-approval }
- { id: review-to-decide, source: finance-approval, target: decide }In a Package
The reviewers belong to whoever installs the Package, so people never travel:
-
A literal (or mixed)
assignedTois stripped. The node carriesapproverRequired: truein its place, and the installer is asked for the approvers' email addresses at install.buildandvalidatewarn about it (HUMAN_REVIEW_APPROVER_STRIPPED). -
An
assignedTomade only of{{ }}expressions names nobody until a run resolves it, so it travels (withapproverRequired: false). The installer may still give an approver; it becomes the node'sfallbackAssignedTo. -
fallbackAssignedTois always stripped. -
Travels:
title,message,responseFields,timeoutValue,timeoutUnit,onTimeout, and for a batchmode,itemsExpression,itemLabel,itemFields -
Converted: none
-
Never carried:
assignedTo(unless expression-only),fallbackAssignedTo
2. A budget threshold gate
Not something you place in the graph. If your organization has budgets configured and a run approaches its cap, Elizon parks the run before the next billable node and asks for approval to continue spending. The reviewer sees a message of the form "This workflow run needs approval before it can continue: its <meter> budget is at 85% of the configured cap."
This gate is a yes/no verdict:
- Approved → the run returns to
runningand continues. - Rejected, or unanswered after 24 hours → the run ends as
failed, with the refusal as its reason. The timeout here is a fixed 24 hours; there is no per-gate setting, because nobody authored the gate.
A run stopped before it started by an exhausted budget is a different outcome entirely — status policy_blocked, decided pre-flight, with no node having run.
Responding to a review
Reviews are answered in Studio, on the Reviews page (the sidebar item shows how many are waiting). It lists every pending review assigned to you in the organization you are signed in to — workflow reviews and Team-run approvals — grouped by workflow or team, with the project, a link to the run, the deadline and what happens at it ("Auto-approves in 3 h"), and whether its fields are pre-filled.
A review is answered by an organization member who holds the workflow-review permission and whose email address is on the request's assignee list. Both checks apply — the permission decides who can reach the review surface at all, the email match decides who can answer this particular request.
There is no public API for submitting a review response
Approving or answering a review is a Studio action, not a public-API endpoint. Your integration
can observe that a run is awaiting_approval by polling GET /workflow-jobs/:jobId, but it cannot resolve the review on
the reviewer's behalf.
What this means for your integration
- Never call the synchronous endpoint on a graph that can park on a person. It will hit the 30-second limit and return
504. Use/run-asyncand poll instead. awaiting_approvalis in flight, not terminal. It counts against your concurrency ceiling for the whole time it is parked — which, with a 30-day timeout, can be a long time. On the community plan, ten workflows waiting on a reviewer will block every other run in the organization.- Poll patiently. A run waiting on a human is measured in hours or days. Back your polling right off, or cancel the run with
POST /workflow-jobs/:jobId/cancelif you no longer need it.
Where to go next
- Workflows API reference — polling a run's status.
- Building a workflow — the rest of the node types.