Messages and Signals
Messages and signals let process instances communicate — with each other, with starting events that haven't fired yet, and with anything outside the engine that can speak HTTP. They look superficially similar in the modeler, but their delivery semantics are very different and they're not interchangeable.
| Aspect | Message | Signal |
|---|---|---|
| Delivery | Point-to-point — one publish wakes at most one subscriber | Broadcast — one publish wakes every subscriber |
| Selection | Name + correlation keys | Name only |
| Late subscribers see buffered ones? | Yes, until the buffer entry is consumed or expires | Yes, until the buffer entry expires |
| Default buffer TTL | 1 hour | 24 hours |
| Used for | Request/response, awaiting an external system, addressing a specific instance | Abort, mass notification, fanning out a fact to whoever cares |
Modeling
Both are declared as top-level definitions in the <definitions> root:
<!-- Message used to correlate to an existing instance — REQUIRES correlationKey. -->
<bpmn:message id="Msg_Payment" name="PaymentReceived">
<bpmn:extensionElements>
<zeebe:subscription correlationKey="=orderId" />
<quantum:ttl>PT1H</quantum:ttl>
</bpmn:extensionElements>
</bpmn:message>
<!-- Message used to start new instances — correlationKey OPTIONAL. -->
<bpmn:message id="Msg_OrderCreated" name="OrderCreated"/>
<bpmn:signal id="Sig_Abort" name="AbortRequested">
<bpmn:extensionElements>
<quantum:ttl>PT30M</quantum:ttl>
</bpmn:extensionElements>
</bpmn:signal>
Every <bpmn:message> plays exactly one role in your model:
- Source for a process-root start event — creates new instances.
correlationKeyis OPTIONAL. - Anything else (intermediate catch, boundary, event-subprocess start, receive task, intermediate throw, end event) — correlates within an instance.
correlationKeyis REQUIRED.
Mixing the two — using the same <bpmn:message> as both a start trigger AND a catch-side correlator — is rejected at deploy time. Split into two separate <bpmn:message> definitions.
See Global definitions for the full attribute reference.
The element pages cover all the catch and throw positions where messages and signals can be used:
| Position | Message? | Signal? | Correlation required? |
|---|---|---|---|
| Process start event (new instance) | ✓ | ✓ | optional |
| Event sub-process start | ✓ | ✓ | required |
| Intermediate catch | ✓ | ✓ | required |
| Intermediate throw | ✓ | ✓ | required |
| Boundary event (interrupting and non-interrupting) | ✓ | ✓ | required |
| End event | ✓ | — | required |
| Receive task | ✓ | — | required |
| Send task | ✓ | — | required |
For per-position attributes, see Events.
Messages
A message is delivered to exactly one subscriber. If several catches are subscribed when a publish arrives, the engine picks one and the rest stay registered for later publishes.
Correlation
A message subscription matches a publish when:
- The names match (the resolved
namefrom the<bpmn:message>definition). - The subscriber's correlation keys are a superset of the publisher's keys.
Containment is recursive — nested objects work the same way. A subscription whose key resolved to { orderId: 123, region: "EU" } matches a publish that carries { orderId: 123 }, but not the other way around. Treat the publisher's keys as a filter on the subscriber population.
Matching is type-sensitive: a subscription whose correlation key resolves to the number 123 does not match a publish that sends the string "123". Watch this when one side reads a JSON payload and the other reads a typed variable.
Two correlation patterns
Both patterns use the same <zeebe:subscription correlationKey> shape — the only difference is which FEEL expression you write.
Same-workflow (self-correlation)
To address the publishing instance only, use the auto-injected quantum.workflowId:
<bpmn:message id="Msg_Resume" name="ResumeAfterPause">
<bpmn:extensionElements>
<zeebe:subscription correlationKey="=quantum.workflowId" />
</bpmn:extensionElements>
</bpmn:message>
<!-- elsewhere in the same process -->
<bpmn:intermediateThrowEvent id="throw-resume">
<bpmn:messageEventDefinition messageRef="Msg_Resume" />
</bpmn:intermediateThrowEvent>
<bpmn:intermediateCatchEvent id="catch-resume">
<bpmn:messageEventDefinition messageRef="Msg_Resume" />
</bpmn:intermediateCatchEvent>
quantum.workflowId is the running instance's unique identifier — the engine surfaces it to FEEL on both throw and catch sides. Same instance ⇒ same id ⇒ correlation matches. Other instances of the same process see different ids and naturally don't correlate.
Cross-instance (application-level key)
To bridge two instances by some domain identifier, evaluate against your own variables:
<bpmn:message id="Msg_PaymentReceived" name="PaymentReceived">
<bpmn:extensionElements>
<zeebe:subscription correlationKey="=orderId" />
</bpmn:extensionElements>
</bpmn:message>
A catch in an order-processing instance evaluates =orderId against its own scope and registers under, say, 42. An external publish of PaymentReceived with correlation 42 then wakes that specific instance.
TTL
When a message is published with no waiting subscriber, the engine buffers it. The next subscriber to register that matches the name and correlation gets the buffered message and the buffer entry is consumed.
Each buffered message carries an expiry. If nobody correlates within the TTL, the entry is dropped.
| Source | Notes |
|---|---|
| Per-publish via API | Pass a ttl field with the publish request. Three formats accepted: ISO 8601 duration (PT1H, P1DT12H), Go-style duration (1h30m), or RFC 3339 absolute timestamp (2026-12-31T23:59:59Z) |
| Per-message in the model | Set <quantum:ttl> inside the <bpmn:message> definition. Workflow-internal publishers (intermediate throw, send task, message end event) use this |
| Server default | Used when neither caller nor model specifies one. 1 hour for messages |
Publishing from outside the engine
Anything that can speak HTTP can publish a message:
POST /projects/{projectID}/bpmn/messages
| Field | Required | Notes |
|---|---|---|
messageName | yes | The message name catches subscribed under |
correlationKeys | conditional | A primitive (string / number / boolean) or an object. Required to deliver to in-flight catch / boundary / ESP / receive listeners — must match the value the message def's correlationKey FEEL expression produces. Omit it (or send an empty object) only when triggering process-root message start events, which are matched by name alone and create new instances. |
variables | no | Variable map merged into the receiving instance's scope |
ttl | no | Buffer expiry, formats as above. Defaults to 1 hour |
curl -X POST "$API/projects/$PROJECT/bpmn/messages" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messageName": "PaymentReceived",
"correlationKeys": 42,
"variables": { "amount": 49.95, "currency": "USD" },
"ttl": "PT1H"
}'
The same operation is available from the run panel on the instance detail page.
Signals
A signal is broadcast to every subscriber that matches by name. There's no correlation — the only filter is the signal name.
<bpmn:signal id="Sig_Abort" name="AbortRequested" />
<bpmn:intermediateThrowEvent id="throw-abort">
<bpmn:signalEventDefinition signalRef="Sig_Abort" />
</bpmn:intermediateThrowEvent>
<!-- in some other process: a non-interrupting boundary that listens for the abort -->
<bpmn:boundaryEvent id="abort-boundary" attachedToRef="long-task" cancelActivity="true">
<bpmn:signalEventDefinition signalRef="Sig_Abort" />
</bpmn:boundaryEvent>
Every running process that catches Sig_Abort receives it.
Buffer and TTL
Signals have a buffer too, but the semantics differ from messages:
- A buffered signal is not consumed when a subscriber reads it. Late subscribers can still pick up a recent signal as long as the buffer entry is alive.
- The default TTL for signal buffer entries is 24 hours, deliberately longer than the message default.
- TTL accepts the same three formats as messages: ISO 8601 duration, Go duration, or RFC 3339 timestamp.
- Per-publish (API), per-signal (
<quantum:ttl>in the model), and the server default work the same way.
Publishing from outside the engine
POST /projects/{projectID}/bpmn/signals
| Field | Required | Notes |
|---|---|---|
signalName | yes | The signal name |
variables | no | Variable map merged into each receiving instance |
ttl | no | Buffer expiry. Defaults to 24 hours |
curl -X POST "$API/projects/$PROJECT/bpmn/signals" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"signalName": "AbortRequested",
"variables": { "reason": "operator-cancelled" }
}'
The same operation is available from the run panel.
Choosing between them
A few patterns to keep in mind:
| You want to… | Use |
|---|---|
| Wake one specific instance by some application key | Message with cross-instance correlation (correlationKey="=orderId" etc.) |
| Coordinate two parts of the same process | Message with self-correlation (correlationKey="=quantum.workflowId") |
| Tell every running instance "stop now" | Signal |
| Notify all instances of a config change | Signal |
| Receive a callback from an external system about a specific request | Message with correlation on the request ID |
| Trigger a new process instance from an external system | Message start event (correlation optional — matched by name) |
Both are point-to-name — there's no per-tenant or per-project isolation beyond the project boundary itself. Names are matched as-is, so prefix conventions (OrderProcess.PaymentReceived) help avoid collisions.