Events and Signals
Stateway supports two ways to inject external events into running process instances: instance events (targeted at a single instance, optionally with an event name that is matched against what the instance is waiting for) and broadcast signals (a tenant-wide fan-out, matched by signal name against every running instance — see Broadcast Signals for exactly what that means).
Instance Events
Send a named event to a specific process instance. The event resumes only the elements that declare the name sent in event_type. It never completes a token just because the token is waiting: a userTask is completed only through the Tasks API.
Request:
curl -X POST https://api.stateway.io/v1/instances/{instance_id}/events \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"event_type": "payment_received",
"variables": {
"paymentId": "pay-001",
"amount": 1500,
"method": "credit_card"
}
}'
Response:
{
"data": {
"event_type": "payment_received",
"processed": false,
"resolved_tokens": []
}
}
In this example no waiting element of the instance declares the name payment_received. The event is recorded and nothing advances (processed: false). A catch event that reaches that name later consumes it. To resume an element by name, see Waiting for a Named Event and Async Service Task Callback.
The body accepts only event_type (string) and variables (optional object). Any other field is rejected (see Error conditions).
Which token does the event resume?
An element declares a name when it is:
- an
intermediateCatchEventwith an event name (stateway:eventCorrelation, or a nativemessageEventDefinitionwith amessageRef); - a message boundary event declared the same way;
- an async service task whose
callbackEventequals theevent_type.
A Signal catch event or boundary declares a signal name. Signals are matched only by Broadcast Signals, in a separate namespace from event_type.
| Situation | What happens | Response |
|---|---|---|
| At least one waiting token declares this name | All tokens that declare this name are resolved (an event is a broadcast within the instance). The event is also recorded for the instance, so a catch event that has not been reached yet still sees it (see Waiting for a Named Event). | processed: true if at least one match resolved, with one resolved_tokens entry per resolved token |
| No waiting token declares this name | The event is recorded for the instance and nothing advances now. A catch event that reaches this name later consumes it. Tokens waiting for anything else (a user task, another name, a timer, a called process) are left alone. | processed: false, resolved_tokens: [] |
| No token is waiting yet, but the instance is still executing an element | The event is recorded for the instance and delivered when a token reaches a matching catch event. | processed: false, resolved_tokens: [] |
event_type is not sent | Nothing is recorded and nothing advances: without a name, there is no element to resume. | event_type: null, processed: false, resolved_tokens: [] |
Each entry of resolved_tokens has these fields:
| Field | Description |
|---|---|
element_id | ID of the element the token was waiting at |
token_id | ID of the resolved token |
element_type | intermediateCatchEvent, boundaryEvent, or serviceTask |
resolution | completed — the catch event was completed by this request. dispatched — the async service task job was marked completed and the engine resumes the token right after, asynchronously. boundary_triggered — a message boundary event fired: it interrupted its host activity (or opened a parallel branch, depending on cancelActivity) |
For a matching element that declares ioMapping outputs, those outputs are applied to the variables you send and only the mapped result is merged into the instance; without outputs, the whole variables object is merged.
Example response when one async service task and one catch event both declared the name:
{
"data": {
"event_type": "payment_completed",
"processed": true,
"resolved_tokens": [
{
"element_id": "processPayment",
"token_id": "3f8b7c1e-...",
"element_type": "serviceTask",
"resolution": "dispatched"
},
{
"element_id": "waitForPayment",
"token_id": "a91d02b4-...",
"element_type": "intermediateCatchEvent",
"resolution": "completed"
}
]
}
}
Use Cases
- A payment gateway confirms a successful charge
- An async service task callback returns a result
- An external API finishes a long-running operation
- An IoT device reports a sensor reading
Error conditions
| Status | Code | Cause |
|---|---|---|
404 | NOT_FOUND | Instance does not exist or belongs to another tenant |
404 | NOT_FOUND | event_type was sent and the instance has neither a waiting nor an active token (nothing is running for the event to reach) |
422 | PROCESS_ERROR | Instance is in completed, terminated, or error state |
400 | VALIDATION_ERROR | The body contains an unknown field (for example eventName instead of event_type); the message lists the offending field names |
If you send event_type while the instance has no token in waiting state yet, but is still executing an element (for example a token is being routed through a gateway or an automated task on its way to a catch event), the event is recorded and the request returns 202 with processed: false and an empty resolved_tokens. A catch event reached later consumes the recorded event. The request returns 404 only when the instance has neither a waiting nor an active token. See Waiting for a Named Event.
Waiting for a Named Event
An intermediateCatchEvent that declares an event name pauses the token until a named event with that name occurs in the same instance. Declare the name with the stateway:eventCorrelation extension element (BPMN) or the eventName field (JSON). In BPMN you can instead use a native messageEventDefinition that references a <bpmn:message>: the message's name (its id if it has no name) becomes the event name, the one you send as event_type. If an element declares both, stateway:eventCorrelation takes precedence. An optional ioMapping maps the event payload into instance variables when the token resumes.
BPMN:
<bpmn:intermediateCatchEvent id="waitForApproval" name="Wait For Approval">
<bpmn:extensionElements>
<stateway:eventCorrelation eventName="orderApproved" />
<stateway:ioMapping>
<stateway:output source="{{approved}}" target="isApproved" />
</stateway:ioMapping>
</bpmn:extensionElements>
<bpmn:incoming>flow1</bpmn:incoming>
<bpmn:outgoing>flow2</bpmn:outgoing>
</bpmn:intermediateCatchEvent>
The same wait declared with a native message definition, with no extension needed to name the event (the ioMapping above is independent of how the name is declared):
<bpmn:message id="msgOrderApproved" name="orderApproved" />
<bpmn:intermediateCatchEvent id="waitForApproval" name="Wait For Approval">
<bpmn:incoming>flow1</bpmn:incoming>
<bpmn:outgoing>flow2</bpmn:outgoing>
<bpmn:messageEventDefinition messageRef="msgOrderApproved" />
</bpmn:intermediateCatchEvent>
Here the event name is orderApproved. A messageEventDefinition with no messageRef has no name to match, so it is treated as a catch event without a name.
JSON:
{
"id": "waitForApproval",
"type": "intermediateCatchEvent",
"incoming": ["flow1"],
"outgoing": ["flow2"],
"eventName": "orderApproved",
"ioMapping": {
"outputs": [{ "source": "{{approved}}", "target": "isApproved" }]
}
}
In ioMapping.outputs, source is an expression evaluated against the event payload (the variables you send, or the HTTP response body of a sendTask); a bare {{approved}} reads the payload field approved. target is always a plain variable name.
To resume the token above:
curl -X POST https://api.stateway.io/v1/instances/{instance_id}/events \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "event_type": "orderApproved", "variables": { "approved": true } }'
The instance continues with isApproved = true.
Who can produce the event
- The API —
POST /instances/:id/eventswith a matchingevent_type, as above. - A
sendTaskwith acallbackEventheader. When its HTTP call succeeds (a2xxresponse), the engine fires an event with that name in the same instance, using the response body as the payload. A failed or timed-out call fires nothing. ThesendTaskitself never waits: its token completes immediately, and the event is fired in the background afterwards.
{
"id": "notify",
"type": "sendTask",
"outgoing": ["flow3"],
"taskHeaders": {
"url": "https://notifications.example.com/confirm",
"method": "POST",
"callbackEvent": "orderApproved"
}
}
- A throw event in the same definition: an
intermediateThrowEventorendEventthat declares an event name fires that event in its own instance when a token reaches it. No API call is involved. See Throwing Named Events from a Process.
Events that occur before the token arrives are not lost
Every named event fired for an instance is recorded. When a token reaches a catch event, the engine first looks for an already-recorded event with that name in the same instance; if it finds one, the token continues immediately without ever waiting, using that event's payload. This closes the race between a fast sendTask on one parallel branch and a catch event on another. Events are scoped to the instance: an event fired on one instance is never seen by another.
This also holds for events sent through the API: a named event is recorded whether a token is already waiting or the instance is still executing an element and no token is waiting yet. The only case where it is rejected is described in Error conditions (the instance has neither a waiting nor an active token).
One firing per instance
Recorded events are not consumed. Any catch event that declares a name for which the instance already has a recorded event completes immediately, using the most recent recorded payload for that name. So a second catch event with the same name, or a catch event visited again in a loop, does not wait for a new event: it completes at once with the earlier payload. Use distinct event names when each wait must be resolved by its own event.
Catch events without a name
An intermediateCatchEvent that declares an event name (stateway:eventCorrelation / eventName, or a native messageEventDefinition with a messageRef) waits for that named event, and one that declares a signal name (signalRef) waits for that signal. One without either has nothing to match against: a Timer catch event waits for its timer, and any other catch event (a Message catch event with no messageRef, or a Conditional catch event) passes through immediately without waiting. See Processes for the element reference.
Message Boundary Events
A boundaryEvent attached to an activity (task, userTask, serviceTask, scriptTask, businessRuleTask, sendTask, receiveTask, subProcess, or callActivity) can declare the same stateway:eventCorrelation extension or native messageEventDefinition with a messageRef (BPMN), or eventName field (JSON), as a named catch event — see Waiting for a Named Event above for the syntax. It arms as soon as the host activity enters waiting and fires when a named event with that name occurs, same as a catch event, but acting on the activity it is attached to instead of being the waiting point itself:
- Interrupting (
cancelActivity: true, the default): the host activity is cancelled — any pending human task or in-flight async service task job it owns is cancelled too — and execution continues from the boundary's own outgoing flow. It never re-arms, since firing it kills the host. When the host is asubProcess(see Processes), this cancels the entire nested flow it opened — every token still inside the sub-process, at any depth, along with the human tasks and service task jobs they own — not just side records of the sub-process activity itself. When the host is acallActivity, the same applies one level further out: the process instance it started is terminated, recursively, along with everything that instance owns. - Non-interrupting (
cancelActivity: false): the host activity is left untouched and keeps running (or waiting); the boundary's outgoing flow opens a parallel branch. It re-arms immediately after firing and reacts again to any further occurrence of the same named event while the host stays active — BPMN gives a non-interrupting boundary no repeat count, unlike a Timer'stimeCycle.
BPMN:
<bpmn:userTask id="review" name="Review">
<bpmn:outgoing>flow1</bpmn:outgoing>
</bpmn:userTask>
<bpmn:boundaryEvent id="onCancel" attachedToRef="review" cancelActivity="true">
<bpmn:extensionElements>
<stateway:eventCorrelation eventName="applicationCancelled" />
<stateway:ioMapping>
<stateway:output source="{{reason}}" target="cancelReason" />
</stateway:ioMapping>
</bpmn:extensionElements>
<bpmn:outgoing>flow2</bpmn:outgoing>
</bpmn:boundaryEvent>
Posting { "event_type": "applicationCancelled", "variables": { "reason": "customer request" } } to the instance cancels the pending review task and continues from flow2, with cancelReason = "customer request" merged into the instance variables.
Events recorded before the boundary armed are not inherited
Unlike a plain catch event (which consumes any already-recorded event for its name the moment its token arrives, see above), a boundary event only reacts to an event recorded after it (most recently) armed — that is, after the host activity entered waiting, or after a non-interrupting boundary re-armed following its previous fire. An event with the same name recorded earlier in the instance (for example, while an unrelated, earlier activity was running, or before a re-arm) does not retroactively fire the boundary. This is intentional: a boundary is a guard over one activity's execution window, not a standing wait point — firing it for something that happened before that window opened would interrupt (or branch off) an activity nobody has had a chance to work on.
Signal Boundary Events
A boundaryEvent or intermediateCatchEvent can declare a bpmn:signalEventDefinition (BPMN, pointing at a root <bpmn:signal> element) or a signalRef field (JSON, the signal name directly) — matched by Broadcast Signals below, not by event_type. A signal boundary arms as soon as its host activity enters waiting and fires when a broadcast with that signal name arrives, same shape as a message boundary, but in a separate namespace: a POST /instances/:id/events with event_type equal to a signal name never fires it, and a signal broadcast never fires a message boundary.
- Interrupting (
cancelActivity: true, the default): the host activity is cancelled — any pending human task or in-flight async service task job it owns is cancelled too — and execution continues from the boundary's own outgoing flow. It never re-arms, since firing it kills the host. - Non-interrupting (
cancelActivity: false): the host activity is left untouched and keeps running (or waiting); the boundary's outgoing flow opens a parallel branch. It re-arms immediately after firing and reacts again to any further broadcast of the same signal name while the host stays active — BPMN gives a non-interrupting boundary no repeat count.
BPMN:
<bpmn:signal id="sig_emergency_stop" name="emergencyStop" />
...
<bpmn:userTask id="review" name="Review">
<bpmn:outgoing>flow1</bpmn:outgoing>
</bpmn:userTask>
<bpmn:boundaryEvent id="onEmergency" attachedToRef="review" cancelActivity="true">
<bpmn:extensionElements>
<stateway:ioMapping>
<stateway:output source="{{status}}" target="emergencyStatus" />
</stateway:ioMapping>
</bpmn:extensionElements>
<bpmn:outgoing>flow2</bpmn:outgoing>
<bpmn:signalEventDefinition signalRef="sig_emergency_stop" />
</bpmn:boundaryEvent>
Broadcasting POST /events/signal/emergencyStop with { "variables": { "status": "critical" } } cancels the pending review task and continues from flow2, with emergencyStatus = "critical" merged into the instance variables.
No replay of signals emitted before the boundary armed
Unlike a message boundary (which replays an already-recorded event via a temporal anchor, see above), a signal boundary has no durable log to replay from: a broadcast signal is transient. If the broadcast happens before the boundary (or a signal catch event) (re-)armed, it is simply missed — the same as it would be missed by any other token type today. Arm the boundary (start the instance, or drive it to the host activity) before broadcasting the signal it should react to.
Throwing Signals from a Process
An intermediateThrowEvent or endEvent that declares a bpmn:signalEventDefinition (BPMN) or a signalRef field (JSON) throws that signal when a token reaches it: Stateway broadcasts it tenant-wide through the same path as POST /events/signal/:signal_name, advancing every waiting signal boundary event and signal intermediateCatchEvent with the same name. No external HTTP call is needed. An intermediateThrowEvent sends the variables listed in its variableFilter as the payload; an endEvent sends none. A failure advancing one catcher is logged and does not stop the thrower's own token. As with any broadcast, a catcher that is not yet waiting does not see the signal.
Throwing Named Events from a Process
An intermediateThrowEvent or endEvent that declares an event name throws that event in its own instance when a token reaches it. In BPMN, declare a native messageEventDefinition with a messageRef (the message's name is the event name) or the stateway:eventCorrelation extension. The extension wins if both are present. In JSON, use the eventName field. The event takes the same path as POST /instances/:id/events: it is recorded, and every waiting catch event and message boundary event of the instance with that name reacts to it. Other instances, including instances of the same definition, never see it. This is the difference from throwing a signal, which is broadcast to the whole tenant.
Use it to coordinate parallel branches of one instance. In the example below, a customer cancels a ticket in their own task. The throw event interrupts the agent's task through its boundary, and the process follows the boundary's flow.
<bpmn:message id="msg_cancel" name="ticketCancelled" />
...
<bpmn:userTask id="customerReview" name="Review ticket">
<bpmn:outgoing>toCancel</bpmn:outgoing>
</bpmn:userTask>
<bpmn:intermediateThrowEvent id="cancelTicket" name="Ticket cancelled">
<bpmn:extensionElements>
<stateway:variableFilter variables="cancelReason" />
</bpmn:extensionElements>
<bpmn:incoming>toCancel</bpmn:incoming>
<bpmn:outgoing>toEnd</bpmn:outgoing>
<bpmn:messageEventDefinition messageRef="msg_cancel" />
</bpmn:intermediateThrowEvent>
<!-- parallel branch -->
<bpmn:userTask id="agentWork" name="Handle ticket">
<bpmn:outgoing>toDone</bpmn:outgoing>
</bpmn:userTask>
<bpmn:boundaryEvent id="onCancelled" attachedToRef="agentWork" cancelActivity="true">
<bpmn:outgoing>toCancelled</bpmn:outgoing>
<bpmn:messageEventDefinition messageRef="msg_cancel" />
</bpmn:boundaryEvent>
When the customer completes customerReview, the pending agentWork task is cancelled and execution continues from toCancelled. Frontend Gateway users who could see agentWork receive task.cancelled with reason: 'boundary'.
- Payload: the variables listed in the element's
variableFilter. A catch or boundary event can map them with itsioMappingoutputs. The variables are already in the instance, so the payload mostly matters for that mapping, and for a catch event that arrives later and reads the values recorded at the moment of the throw. - Recording: the same rules as an API event apply. A catch event that arrives later finds the recorded event (Events that occur before the token arrives are not lost), and a boundary that arms later does not inherit it (Events recorded before the boundary armed are not inherited).
- Notification: the throw also publishes the element's notification, and the notification carries the
eventName. AnendEventpublishes it only when it throws a named event. - Without a name: an
intermediateThrowEventwith amessageEventDefinitionbut nomessageRefthrows no event and only publishes its notification, as before. AnendEventwith amessageEventDefinitionand no name is rejected when the definition is published, because it would do nothing. - A throw event declares a signal or an event name, not both. A definition that declares both is rejected when it is published.
Ending Every Path: Terminate End Event
An endEvent with a terminateEventDefinition (BPMN) or "terminate": true (JSON) ends every other active path of its scope when a token reaches it. Open user tasks on those paths are cancelled, along with pending async service task jobs, sub-processes and instances started by a callActivity.
- At the top level of the process, the instance ends with status
completed. This is the normal end of the process, not theterminatedstatus ofPOST /v1/instances/:id/terminate. - Inside a
subProcess, only the paths of that sub-process end. The sub-process completes and the parent flow continues from its outgoing flow. - Inside the process started by a
callActivity, that instance completes and the caller continues.
A terminate end event cannot also throw a signal or a named event. A definition that combines them is rejected when it is published.
Broadcast Signals
Broadcast a signal to every waiting token across all running instances of the tenant that declares a matching signal name — a signal boundary event or a signal intermediateCatchEvent. A token that declares a different signal name, or no signal name at all, is left untouched.
Before this, the signal name in the URL was not matched against anything: every token in waiting state in a running instance of your tenant was completed by any signal, whatever it was waiting for. It is now matched by name — a signal only advances the tokens that declare that signal.
Request:
curl -X POST https://api.stateway.io/v1/events/signal/price_update \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"variables": {
"productId": "prod-001",
"newPrice": 99.99
}
}'
Response:
{
"data": {
"signal_name": "price_update",
"processed": 3,
"failed": 0,
"errors": []
}
}
If processing a particular token raises an error, that token's failure does not abort the broadcast — the remaining tokens are still processed:
{
"data": {
"signal_name": "price_update",
"processed": 2,
"failed": 1,
"errors": [
{
"token_id": "9f2e1b3a-...",
"instance_id": "6c1d4f2a-...",
"message": "Variables payload size 5300021 bytes exceeds maximum 5242880 bytes (MAX_VARIABLES_PAYLOAD_BYTES)."
}
]
}
}
The processed field indicates how many tokens declaring this signal name were successfully resolved without error (a token that was already completed by an earlier, idempotent broadcast retry is also counted here, since completeToken()/dispatchBoundary() no-op rather than erroring). For a matching boundary event, the mapped ioMapping outputs are merged into the instance (or the whole variables object, if no mapping is declared); for a matching signal catch event, the same mapping applies. failed counts tokens whose resolution raised an error; the most common cause is the variables merge exceeding the size limit, in which case that token is reverted to dead and its instance transitions to error (recoverable via Token Move). Other, rarer causes (for example, a transient error reading the process definition) leave the token waiting and the instance untouched. A token whose declared signal name does not match is neither processed nor failed — it is simply left alone.
How Signals Work
- The engine queries all tokens with
status = waitingacross allrunninginstances of the tenant - For each, it resolves the token's element and compares its declared signal name (
signalRef) againstsignal_namein the URL — only a match advances - A matching boundary event is dispatched via the same interrupting/non-interrupting mechanism as a signal boundary; a matching signal catch event is completed directly
- The mapped
variablespayload is merged into each affected instance's variables
Use Cases
Because a broadcast reaches every running instance of the tenant, it fits a signal that any number of instances might be listening for at once — an emergency stop, a market-wide price update, a global maintenance window:
- The
variablesyou send are mapped throughioMappingfor the matching element, same as instance events - Only tokens that declare the matching signal name are resolved; everything else (user tasks, async service tasks, tokens declaring a different or no signal name) is left untouched
- Instances that are not
runningare skipped
Instance Events vs. Broadcast Signals
| Aspect | Instance Event | Broadcast Signal |
|---|---|---|
| Target | One specific instance | Every matching token across the tenant's running instances |
| Event name | event_type, matched against catch events, message boundary events and async service task callbacks (see Which token does the event resume?) | signal_name in the URL, matched against the signalRef declared by signal boundaries and catch events — a separate namespace from event_type |
| Variables | Merged into that instance | Merged into every affected instance that declares the matching signal |
| Endpoint | POST /instances/:id/events | POST /events/signal/:signal_name |
| Use case | Callbacks, async completions, named-event correlation | Tenant-wide, cross-instance signal (emergency stop, broadcast price update) |
Async Service Task Callback
The most common pattern is completing an async service task. The BPMN configures executionMode: async with a callbackEvent name (required in async mode: a definition without it is rejected at publish time), and the external service POSTs to the instance event endpoint with that name as event_type when done:
<bpmn:serviceTask id="processPayment" name="Process Payment">
<bpmn:extensionElements>
<stateway:taskDefinition type="http" />
<stateway:taskHeaders>
<stateway:taskHeader key="url" value="https://payments.example.com/charge" />
<stateway:taskHeader key="executionMode" value="async" />
<stateway:taskHeader key="callbackEvent" value="payment_completed" />
</stateway:taskHeaders>
<stateway:ioMapping>
<stateway:output source="{{transactionId}}" target="transactionId" />
</stateway:ioMapping>
</bpmn:extensionElements>
</bpmn:serviceTask>
When the payment processor finishes, it calls:
Request:
curl -X POST https://api.stateway.io/v1/instances/{instance_id}/events \
-H "X-API-Key: sw_live_..." \
-H "Content-Type: application/json" \
-d '{
"event_type": "payment_completed",
"variables": { "transactionId": "tx-123", "status": "success" }
}'
Response:
{
"data": {
"event_type": "payment_completed",
"processed": true,
"resolved_tokens": [
{
"element_id": "processPayment",
"token_id": "3f8b7c1e-...",
"element_type": "serviceTask",
"resolution": "dispatched"
}
]
}
}
When event_type matches the task's callbackEvent, the task's ioMapping outputs are applied to the posted variables (without outputs, the variables are used as they are), the service task job is marked completed with that mapped result as its response, and the token resumes. A callback without event_type, or with a name the task does not declare, does not resume it. See the service tasks guide for the async mode itself.
Best Practices
- Use descriptive event names —
payment_receivednotevent1 - Include context in variables — always include IDs needed for traceability
- Handle missing and finished instances — sending an event to a non-existent instance returns
404, and to a completed, terminated or errored instance returns422 - Use broadcast sparingly — a signal completes every waiting token of every running instance in the tenant, whatever it is waiting for; prefer instance events with an
event_type - Send the declared name in
event_type— an event only resumes the elements that declare that name; without it, nothing advances - Idempotency — the same event sent twice may advance a token twice if the instance returns to a waiting state; design with this in mind. Named catch events are the opposite case: they read the recorded event again, so a repeated visit completes immediately (see One firing per instance)