Sessions API — Authentication and Scope
Session Model
A frontend session is an ephemeral access token you issue for an end user. It carries three pieces of information: the user's identity (user_id), the access scope (which processes, which roles, which operations), and the TTL.
The user_id is the identifier of the user in your system — it can be a UUID, an email address, or any unique string. Stateway does not validate or interpret this value. It is compared with task assignments (assignee, candidateUsers) to decide which tasks the user sees. It is also used to associate instances with the user and for the audit log.
When the user acts through the gateway (startProcess, completeTask, sendEvent), process expressions evaluated while that action moves the process forward can read the session's user_id and user_display as {{session.userId}} and {{session.userDisplay}}. This is the trustworthy way to assign tasks to whoever started the instance, or to record who completed a task, because the browser cannot change it. The values are not kept between steps: to remember who started the instance, record it as a variable on the startEvent. See Assigning to Whoever Started the Instance.
Creating a Session
Call POST /v1/sessions from your backend using your API Key:
curl -X POST https://api.stateway.io/v1/sessions \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"user_id": "user_42",
"user_display": "Jane Smith",
"ttl": 3600,
"scope": {
"definitions": [
{
"key": "expense-approval",
"roles": ["requester"],
"candidate_as_user": false,
"can_start": true,
"max_concurrent": 3,
"searchable_variables": ["amount", "category"]
}
],
"variable_visibility": "declared"
}
}'
Response:
{
"session_id": "sess_01j...",
"session_token": "sws_live_Xk9mP2...",
"user_id": "user_42",
"expires_at": "2026-05-02T15:00:00Z",
"scope": { "..." }
}
The session_token is returned only once. Store it nowhere on the backend — forward it immediately to the frontend. Stateway stores only a SHA-256 hash.
Scope Fields
| Field | Type | Description |
|---|---|---|
definitions[].key | string | Authorized process definition key |
definitions[].roles | string[] | Roles the user can assume — matched against candidateGroups of tasks |
definitions[].candidate_as_user | boolean | If true, includes tasks where user_id appears in candidateUsers. Default: false |
definitions[].can_start | boolean | Whether the user can start new instances of this definition |
definitions[].max_concurrent | integer | null | Maximum simultaneous open instances for this user. null = unlimited |
definitions[].searchable_variables | string[] | "all" | Variable names search() may filter on and return for this definition. Omitted or [] → nothing searchable (deny-by-default). "all" → every variable. See Searchable Variables. |
definitions[].instance_filter | object | Search filter, set by your backend, that limits which instances of this definition the session can reach with search() and sendEvent(). Omitted → every instance of the definition in your tenant. See Instance Filter. |
variable_visibility | "declared" | "all" | Which process variables are delivered with tasks and event snapshots — "declared" (default) = only variables a task declares as data inputs; "all" = every variable (dev/test only). Does not affect search(). |
Roles and Candidate Groups
roles defines which BPMN groups the user can assume. A user receives tasks whose assignee matches their user_id, or whose candidateGroups contains a value listed in roles.
{
"key": "loan-application",
"roles": ["analyst", "supervisor"],
"candidate_as_user": false,
"can_start": false,
"max_concurrent": null
}
In this example, the user receives tasks assigned to them directly or assigned to the groups analyst or supervisor. They cannot start new instances. They may have any number of simultaneous open instances.
candidate_as_user
The candidate_as_user field instructs Stateway to include tasks where the user's user_id appears in candidateUsers. Enable this when the process may name users individually as candidates — for example, when an external system designates which specific analyst should review a case:
{
"key": "case-review",
"roles": [],
"candidate_as_user": true,
"can_start": false,
"max_concurrent": null
}
This field is false by default. Enable it only when the process actually uses individual candidateUsers for that definition.
Searchable Variables
searchable_variables controls which variables the search() method of
@stateway/frontend-sdk can filter on and return — and nothing else. It does not limit which
instances come back: without an instance_filter, search() reaches
the instances of every user of your tenant. It is declared per definition, inside each
scope.definitions[] entry:
{
"key": "loan-application",
"roles": ["analyst"],
"searchable_variables": ["loan_status", "amount"]
}
- Entries are plain variable names (
"loan_status"), nevervariables.-prefixed. In asearch()filter you still write the dotted form ({ "variables.loan_status": "in_review" }); the Gateway strips thevariables.prefix before matching it against this list. - Omitted or
[]— no variable is searchable. Anyvariables.*key in asearch()filterortext.fieldsis rejected withSCOPE_VIOLATION, text search is rejected, and every result row is returned with an emptyvariablesobject. This is the secure default. - Text search without
text.fieldssearches only the searchable variables, so it cannot match a value stored in a variable the session cannot see. With more than 20 searchable variables, list the ones to search intext.fields. filtermust be an object. A filter sent as an MQL string is rejected withSCOPE_VIOLATION."all"— every variable is searchable and returned. Intended for trusted internal tooling, not end-user sessions.- Nested paths (
variables.address.city) are matched verbatim — list them as written ("address.city"). Only top-level variable names are returned in results.
searchable_variables is independent of variable_visibility: the former scopes
search(), the latter scopes the variables delivered with tasks and events. Setting one
has no effect on the other.
See the SDK reference search for the
method signature and pagination.
Instance Filter
instance_filter limits which instances of a definition the session can reach. It is a
search filter that your backend sets when it creates the session,
so the browser cannot change it. A session for an end user who should see only their own
requests looks like this:
{
"key": "support-ticket",
"can_start": true,
"searchable_variables": ["subject", "priority", "openedBy"],
"instance_filter": { "variables.openedBy": "user_42" }
}
search()combines it with the filter sent by the browser using$and. The browser can narrow the results further but never widen them.sendEvent()is accepted only for instances that match it. An instance that does not match gets the sameSCOPE_VIOLATIONas one that does not exist.- Omitted — the session reaches every instance of the definition in your tenant. Use this for internal roles, such as support agents, that must see everyone's instances.
- It uses the same operators and fields as
search()filters and counts toward the same limits (nesting depth and number of conditions). The fields it uses do not need to be insearchable_variables. An invalid filter, or an empty object, is rejected with400when the session is created.
The filter is only as trustworthy as the variable it matches. A variable sent by the
browser in startProcess ({ openedBy: user.id }) can name any other user. Fill the
owner variable from the session instead, with ioMapping outputs on the startEvent:
<stateway:output source="{{session.userId}}" target="openedBy" />. See
Recording who started the instance as a variable.
Other Session Endpoints
GET /v1/sessions/:id Inspect active session
DELETE /v1/sessions/:id Revoke session (user logout)
POST /v1/sessions/:id/refresh Extend TTL of active session
Refreshing a session extends the TTL without invalidating the token. Useful for long-lived sessions with silent renewal:
curl -X POST https://api.stateway.io/v1/sessions/sess_01j.../refresh \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "ttl": 3600 }'
The SDK emits a session.expiring event 5 minutes before expiry — a good moment to trigger a refresh:
sw.on('session.expiring', async ({ expiresIn }) => {
if (expiresIn < 300) {
const newExpiry = await myBackend.refreshStatewaySession();
// token remains the same; only expires_at changes
}
});
Session Continuity
If a user logs out and back in — creating a new session with the same user_id — running instances are automatically transferred to the new session. The previous session is invalidated. No instances are lost.
The session.superseded event is emitted to any active WebSocket connection using the old session token, and the connection is closed by the proxy.