Human Tasks
Human tasks (userTask) pause process execution until a user manually completes them. They are the primary mechanism for involving people in automated workflows.
Task Lifecycle
┌─────────┐ claim ┌─────────┐ complete ┌───────────┐
│ pending │──────────→ │ claimed │──────────────→│ completed │
└────┬────┘ └────┬────┘ └─── ────────┘
│ ↑ cancel ↙
│ └──────────────┘
│ unclaim /
│ delegate
│ cancel
↓
┌───────────┐
│ cancelled │
└───────────┘
| Status | Description |
|---|---|
pending | Task is waiting; no one has claimed it yet |
claimed | A user has taken ownership |
completed | Task is done; process continues to the next element |
cancelled | Task was cancelled before completion: a boundary event interrupted it, its sub-process or path ended (including a terminate end event), the call activity that started its process was interrupted, the process instance was terminated, or it was cancelled through the API. Frontend Gateway users who could see it receive task.cancelled |
Defining a Human Task in a Process
{
"id": "review-expense",
"type": "userTask",
"name": "Review Expense Report",
"assignee": "{{variables.managerId}}",
"outgoing": ["flow-to-decision"]
}
The process pauses at review-expense and waits for an external API call to complete it. The assignee field supports expressions — see Task Assignment for the full grammar; if omitted, the task goes into the unassigned pool.
A userTask may also declare candidateGroups (an array of group names) to make the task visible to multiple users:
{
"id": "legal-review",
"type": "userTask",
"name": "Legal Review",
"candidateGroups": ["legal", "compliance"],
"outgoing": ["flow-approved"]
}
Each item in candidateGroups (and candidateUsers) may also be an expression — the same grammar as assignee (see Expression Support for namespaces, nested paths, and compound expressions). If it resolves to a comma-separated string, it fans out into multiple candidates — "legal,compliance" becomes ["legal", "compliance"]. An item that resolves to undefined is omitted (a warning is logged), it does not fail the task.
See Variable Expressions for how assignee/candidateGroups/candidateUsers fit into the broader expression model.
candidateGroups is stored as metadata and returned in task responses. The REST API has no user-identity or group model, so it does not check group membership at claim or delegate time — your application is responsible for that. See Authorization & Segregation of Duties for the full picture, including how candidateUsers differs and how the Frontend Gateway enforces the whole assignment union.
To list the tasks visible to a group, use the ?candidate_group=<group> filter on GET /tasks (see Filters).
Listing Tasks
Request:
curl https://api.stateway.io/v1/tasks \
-H "X-API-Key: sw_live_your_key"
Response:
{
"data": [
{
"id": "task_01j...",
"name": "Review Expense Report",
"status": "pending",
"instance_id": "inst_01j...",
"element_id": "review-expense",
"assignee": "alice@acme.com",
"variables": {
"amount": 1500,
"requester": "john@acme.com"
},
"created_at": "2026-04-26T12:00:00.000Z",
"claimed_at": null,
"completed_at": null,
"due_at": null
}
]
}
Filters
# By status
curl "https://api.stateway.io/v1/tasks?status=pending" \
-H "X-API-Key: sw_live_your_key"
# By assignee
curl "https://api.stateway.io/v1/tasks?assignee=alice@acme.com" \
-H "X-API-Key: sw_live_your_key"
# By process instance
curl "https://api.stateway.io/v1/tasks?instance_id=inst_01j..." \
-H "X-API-Key: sw_live_your_key"
# By candidate group (tasks whose candidateGroups contains this group)
curl "https://api.stateway.io/v1/tasks?candidate_group=legal" \
-H "X-API-Key: sw_live_your_key"
status must be one of pending, claimed, completed or cancelled — any other value returns 400.
Pagination
GET /v1/tasks is paginated with page/page_size query parameters, combinable with the filters above:
curl "https://api.stateway.io/v1/tasks?status=pending&page=2&page_size=20" \
-H "X-API-Key: sw_live_your_key"
page_size: results per page. Default50, maximum200. Requests withoutpage/page_sizeget the default page size — the response is not the full unfiltered result set.page: 1-indexed page number. Default1.- Results are ordered by
created_atdescending (most recent first). - A
page_sizeabove200returns400 VALIDATION_ERRORnaming the offending field.
Getting Task Details
Request:
curl https://api.stateway.io/v1/tasks/{task_id} \
-H "X-API-Key: sw_live_your_key"
Response:
{
"data": {
"id": "task_01j...",
"name": "Review Expense Report",
"status": "pending",
"instance_id": "inst_01j...",
"element_id": "review-expense",
"assignee": null,
"variables": {
"amount": 1500,
"requester": "john@acme.com"
},
"created_at": "2026-04-26T12:00:00.000Z",
"claimed_at": null,
"completed_at": null,
"due_at": null
}
}
Authorization & Segregation of Duties
A userTask can name who may take it on through three metadata fields: assignee (a single user identifier), candidateUsers (an explicit list of user identifiers) and candidateGroups (a list of group names). Together they describe a union: a task may be taken by its assignee, or by anyone in candidateUsers, or by anyone in one of its candidateGroups. How much of that union is enforced depends on which surface the claim or delegate request comes through.
REST API (tenant API key) — candidateUsers is enforced whenever the union is checkable. The REST API has no user-identity or group model, so it can only reject a request it can prove ineligible. At claim and delegate time, if a task declares a non-empty candidateUsers list and no candidateGroups, the full candidate pool is known — it is candidateUsers plus the task's assignee, if it names one. A request for an assignee outside that pool gets 403 FORBIDDEN.
REST API — candidateGroups is the integrator's responsibility. When a task names any candidateGroups, part of its candidate pool is not checkable by the REST API (it cannot resolve group membership), so it accepts the claim for any assignee. The same applies to a task with no assignment metadata at all. In those cases your application must verify that the assignee belongs to the task's candidate pool before calling claim or delegate. Use the ?candidate_group=<group> filter on GET /tasks (see Filters) to list the tasks visible to a group.
Frontend Gateway — the full union is enforced. A session may take a task when its user_id matches the task's assignee, when the task's candidateGroups intersect the roles of the session scope, or when its user_id is in candidateUsers and the definition scope sets candidate_as_user: true. A session that is not eligible for a task cannot claim it. To assign a task to the user who started the instance, use assignee="{{session.userId}}", never a variable sent by startProcess. Those variables come from the browser. See Assigning to Whoever Started the Instance and Task Visibility in the Frontend Gateway.
Claiming or delegating an existing task for an assignee the REST API can prove ineligible returns 403 (not 404).
Claiming a Task
Take ownership of a pending task:
Request:
curl -X POST https://api.stateway.io/v1/tasks/{task_id}/claim \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "assignee": "alice@acme.com" }'
Response:
{
"data": {
"id": "task_01j...",
"name": "Review Expense Report",
"status": "claimed",
"instance_id": "inst_01j...",
"assignee": "alice@acme.com",
"claimed_at": "2026-04-26T12:05:00.000Z",
"completed_at": null
}
}
Only tasks with status pending can be claimed. Claiming an already-claimed task returns 404 NOT_FOUND; an ineligible assignee returns 403 FORBIDDEN (see Authorization & Segregation of Duties).
The REST API validates the assignee against candidateUsers only when candidateUsers is the task's sole assignment constraint — no assignee and no candidateGroups on the task. Group membership is never checked by the REST API; enforce it in your application before calling this endpoint.
Completing a Task
Provide output variables and advance the process:
Request:
curl -X POST https://api.stateway.io/v1/tasks/{task_id}/complete \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"variables": {
"approved": true,
"approvedAmount": 1500,
"comments": "Approved — valid business expense"
}
}'
Response:
{
"data": {
"id": "task_01j...",
"status": "completed"
}
}
The engine merges the provided variables into the process instance and resumes execution at the next element.
| Response | Meaning |
|---|---|
200 | Task completed, process resumed |
400 VALIDATION_ERROR | Three distinct causes. (1) The submitted variables is not a plain object. (2) The merged payload would exceed the variables size cap (MAX_VARIABLES_PAYLOAD_BYTES) — for (1)/(2), nothing is mutated in the common case; in the rare case where the size check only fails after a concurrent write grows the instance mid-request, human_tasks is reverted back to its pre-request state before the error is returned, so this endpoint never leaves the task marked completed for a request that didn't actually reach the process instance. (3) The submitted outputs fail the task's declared outputSets validation (same check on the REST API and the Frontend Gateway) — unlike (1)/(2), this cause does mutate state before returning the error: the task is cancelled (if a matching interrupting error boundary event exists on this task) or the process instance is marked error (otherwise). |
409 CONFLICT | Two distinct causes: (1) the task's underlying execution token was already dead (for example, the process instance had already errored for an unrelated reason). The completion was not applied — human_tasks and process_instances are left unchanged. The instance needs recovery (e.g. via token move) before the task can be completed; do not retry with the same request. (2) another concurrent request already completed (or cancelled) this same task and won the race. The task is already in its final state and the instance is healthy — do not attempt token-move recovery in this case. Re-fetch the task (GET /tasks/{task_id}) to see the outcome instead of retrying; this endpoint is not idempotent, so blindly retrying the same request will keep failing. |
Cancelling a Task
Cancel a task without advancing the process:
Request:
curl -X POST https://api.stateway.io/v1/tasks/{task_id}/cancel \
-H "X-API-Key: sw_live_your_key"
Response:
{
"data": {
"id": "task_01j...",
"name": "Review Expense Report",
"status": "cancelled",
"instance_id": "inst_01j...",
"completed_at": "2026-06-07T10:00:00.000Z"
}
}
The task is marked as cancelled. The process is not advanced — the instance remains in its current state.
This endpoint is idempotent: cancelling an already-cancelled task returns 200 with the current task data.
| Task status | Behaviour |
|---|---|
pending | Cancelled → 200 |
claimed | Cancelled → 200 |
cancelled | No-op → 200 |
completed | Cannot cancel → 409 CONFLICT |
Use cancel to clean up tasks that are no longer relevant — for example, after terminating a process instance or when a business event makes the task obsolete. Cancelled tasks are retained in the system and visible via GET /tasks?status=cancelled.
Unclaiming a Task
Release a claimed task back to the pool:
Request:
curl -X POST https://api.stateway.io/v1/tasks/{task_id}/unclaim \
-H "X-API-Key: sw_live_your_key"
Response:
{
"data": {
"id": "task_01j...",
"status": "pending",
"assignee": null,
"claimed_at": null
}
}
The task returns to pending status and assignee is cleared.
Delegating a Task
Reassign a task to a different user:
Request:
curl -X POST https://api.stateway.io/v1/tasks/{task_id}/delegate \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "assignee": "bob@acme.com" }'
Response:
{
"data": {
"id": "task_01j...",
"status": "pending",
"assignee": "bob@acme.com",
"claimed_at": null
}
}
The task is reassigned to bob@acme.com and returns to pending status (the new assignee must claim it before completing).
If the task declares a non-empty candidateUsers list and the new assignee is not in it, the API returns 403 FORBIDDEN (see Authorization & Segregation of Duties).
Task Variables
A task carries a snapshot of its declared input variables, taken at task creation. Only variables the process designer declared as formal inputs of the userTask — through an ioSpecification / data associations (BPMN) or dataInputs (JSON/YAML) — are captured. GET /v1/tasks/{id} returns exactly that snapshot in variables.
A userTask that declares no formal inputs (for example, one that only carries a formSchema) has variables: {} in the REST API — this is expected, not a missing snapshot. Declare the inputs you want the task to read:
ioSpecification: dataInputs: amount, requester dataOutputs: approved
Process variables at task creation:
{ amount: 1500, requester: "john@acme.com", creditScore: 720 }
│ (only declared inputs are snapshotted)
▼
┌────────────────┐
│ userTask │ task.variables = { amount: 1500, requester: "john@acme.com" }
│ Review │
└────────┬───────┘
│ complete({ approved: true, note: "ok" }) ← only declared dataOutputs are merged back
▼
Process variables (after completion):
{ amount: 1500, requester: "john@acme.com", creditScore: 720, approved: true }
When completing a task, you can provide output variables that are merged back into the instance. The same rules apply to the REST API and the Frontend Gateway:
- BPMN
userTaskwith anioSpecification: only the variables declared asdataOutputare merged back; the others are discarded (notein the example above). AnioSpecificationthat declares onlydataInputs merges nothing back.dataOutputAssociations are applied, so an output can be stored under a different process variable — see BPMN Data Visibility. userTaskwithout anioSpecification(including every JSON/YAMLuserTask, sincedataInputsdeclares inputs only): every submitted variable is merged back.ioMappingoutputs take precedence over both — see below.
Through the Frontend Gateway, the task's declared-input snapshot is what a session normally receives in work.snapshot / task.assigned. A session whose scope sets "variable_visibility": "all" receives the full instance variables instead, regardless of what the task declares — intended for development and test only.
Mapping Inputs and Outputs with ioMapping
A userTask can also declare stateway:ioMapping (ioMapping in JSON/YAML), the same mapping used by service tasks:
inputsare resolved when the task is created and added to the task's variables, alongside any formal inputs. If both define the same name, theioMappingvalue wins. Sources can readvariables,instance,sessionandnow().outputsare applied when the task is completed, through the REST API or the Frontend Gateway. Sources read the submitted variables ({{result.decision}}, or{{decision}}),instance,sessionandnow(). Heresessionis the user who completed the task through the Frontend Gateway (empty through the REST API), so{{session.userId}}→resolvedByrecords who acted. When a task declaresoutputs, only the mapped targets are written to the instance and the other submitted variables are discarded; sources still read every submitted variable, even ones not declared asdataOutput. AnydataOutputAssociations on the same task are ignored. Withoutoutputs, theioSpecificationrules above apply.
<bpmn:userTask id="review" name="Review Request">
<bpmn:extensionElements>
<stateway:ioMapping>
<stateway:input source="{{variables.request.title}}" target="title" />
<stateway:output source="{{result.decision}}" target="approvalDecision" />
<stateway:output source="{{now()}}" target="decidedAt" />
</stateway:ioMapping>
</bpmn:extensionElements>
</bpmn:userTask>
The task keeps the variables exactly as submitted (GET /v1/tasks/{id}); the mapping only changes what reaches the instance. Typed output validation (outputSets) runs on the submitted variables, before the mapping or the dataOutput filtering. {{session.*}} is the user who started the instance, not the one completing the task. Under "variable_visibility": "all", a Frontend Gateway session receives the instance variables, so it does not see values that exist only as task inputs.
Patterns
These patterns show common process structures involving human tasks. Use them as building blocks when designing your processes.
Sequential Approval
{
"elements": [
{ "id": "manager-review", "type": "userTask", "name": "Manager Review", "outgoing": ["flow-to-director"] },
{ "id": "director-review", "type": "userTask", "name": "Director Review", "outgoing": ["flow-to-end"] }
],
"flows": [
{ "id": "flow-to-director", "sourceRef": "manager-review", "targetRef": "director-review" },
{ "id": "flow-to-end", "sourceRef": "director-review", "targetRef": "end" }
]
}
Conditional Routing After Task
{
"elements": [
{ "id": "review", "type": "userTask", "name": "Review", "outgoing": ["flow-to-gw"] },
{ "id": "gw", "type": "exclusiveGateway", "outgoing": ["flow-approve", "flow-reject"] },
{ "id": "end-ok", "type": "endEvent" },
{ "id": "end-fail", "type": "endEvent" }
],
"flows": [
{ "id": "flow-to-gw", "sourceRef": "review", "targetRef": "gw" },
{ "id": "flow-approve", "sourceRef": "gw", "targetRef": "end-ok", "condition": "{{variables.approved == true}}" },
{ "id": "flow-reject", "sourceRef": "gw", "targetRef": "end-fail", "condition": "{{variables.approved == false}}" }
]
}
Best Practices
- Claim before completing — ensures proper ownership tracking and prevents race conditions
- Use meaningful task names — these names surface in task lists and webhook payloads
- Include context in variables — provide all information a user needs to make a decision at instance start time
- Pair userTasks with timers — enforce SLAs by running a
timerEventin a parallel branch alongside the task (see SLA Enforcement in the Timers guide); boundary timer events are not supported yet (see Timers) - Filter by
assignee— build task inbox UIs usingGET /tasks?assignee=user@example.com - Subscribe to
task.overdue— use webhooks to notify assignees or trigger escalation flows