Skip to main content

Processes

Stateway supports three formats for process definitions: BPMN 2.0 XML, JSON, and YAML. All three are parsed into the same internal model and run on the same engine. BPMN 2.0 XML is the canonical format — JSON and YAML express a subset of what it can model. They are convenience formats for the same concepts, never a superset with different semantics. See JSON/YAML vs BPMN — Known Limitations for what BPMN can express that JSON/YAML cannot.

Supported Formats​

Formatsource_typeBest For
BPMN 2.0 XMLbpmnStandard interchange; compatible with Camunda Modeler, bpmn.io
JSONjsonProgrammatic creation; AI agents and automated pipelines
YAMLyamlHuman-readable authoring
BPMN (base64)bpmn-base64Transmitting binary BPMN files as JSON strings

JSON/YAML vs BPMN — Known Limitations​

BPMN 2.0 XML is the canonical format. JSON and YAML cover the same execution model, but a few BPMN capabilities have no JSON/YAML equivalent. When you need one of these, author the process — or just the affected elements — in BPMN 2.0 XML.

CapabilityBPMN 2.0 XMLJSON / YAML
Typed I/O specifications — named, typed activity inputs/outputs, form auto-generation, and typed output validation✅ via ioSpecification + itemDefinition❌ Declare the form explicitly with formSchema; task outputs are not type-validated. See Typed I/O & Form Schemas.
Data objects & data stores — itemDefinition, dataObject, dataStoreReference✅ parsed from XML❌ Not expressible in JSON/YAML. See Types & Validation.
Gateway default flow attribute — a named fallback flow✅ via default="flowId"❌ No named default; the fallback is the first outgoing flow with no condition. See Expressions in Conditions.

Everything else — element types, flows, conditions, timers, assignments, service-task definitions, and variableFilter — behaves identically across all three formats.

Creating a Definition​

JSON Format​

The JSON source object requires a key, an elements array, and a flows array. Elements declare their outgoing flow IDs; flows declare their sourceRef and targetRef.

Request:

curl -X POST https://api.stateway.io/v1/definitions \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"key": "expense-approval",
"name": "Expense Approval",
"source_type": "json",
"source": {
"key": "expense-approval",
"elements": [
{ "id": "start", "type": "startEvent", "outgoing": ["flow1"] },
{ "id": "review", "type": "userTask", "name": "Review Expense", "outgoing": ["flow2"] },
{ "id": "end", "type": "endEvent" }
],
"flows": [
{ "id": "flow1", "sourceRef": "start", "targetRef": "review" },
{ "id": "flow2", "sourceRef": "review", "targetRef": "end" }
]
}
}'

Response:

{
"data": {
"id": "def_01j...",
"key": "expense-approval",
"version": 1,
"name": "Expense Approval",
"source_type": "json",
"source_hash": "a3f8c2d1...",
"is_active": true,
"created_at": "2026-04-26T12:00:00.000Z"
}
}

YAML Format​

YAML source is a string that serializes the same structure as JSON:

Request:

curl -X POST https://api.stateway.io/v1/definitions \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"key": "expense-approval",
"name": "Expense Approval",
"source_type": "yaml",
"source": "key: expense-approval\nelements:\n - id: start\n type: startEvent\n outgoing: [flow1]\n - id: review\n type: userTask\n name: Review Expense\n outgoing: [flow2]\n - id: end\n type: endEvent\nflows:\n - id: flow1\n sourceRef: start\n targetRef: review\n - id: flow2\n sourceRef: review\n targetRef: end\n"
}'

Response:

{
"data": {
"id": "def_01j...",
"key": "expense-approval",
"version": 1,
"name": "Expense Approval",
"source_type": "yaml",
"source_hash": "b4d9f7e2...",
"is_active": true,
"created_at": "2026-04-26T12:00:00.000Z"
}
}

BPMN XML Format​

To generate well-formed BPMN 2.0 XML with correct element IDs, namespace declarations, and Stateway extensions, use the bpmn-xml-generator Claude skill.

Pass the raw BPMN XML as a string in source:

Request:

curl -X POST https://api.stateway.io/v1/definitions \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"key": "expense-approval",
"name": "Expense Approval",
"source_type": "bpmn",
"source": "<?xml version=\"1.0\" encoding=\"UTF-8\"?><bpmn:definitions xmlns:bpmn=\"http://www.omg.org/spec/BPMN/20100524/MODEL\" xmlns:stateway=\"https://stateway.io/schema/bpmn/1.0\"><bpmn:process id=\"expense-approval\" isExecutable=\"true\"><bpmn:startEvent id=\"start\"><bpmn:outgoing>flow1</bpmn:outgoing></bpmn:startEvent><bpmn:userTask id=\"review\" name=\"Review Expense\"><bpmn:incoming>flow1</bpmn:incoming><bpmn:outgoing>flow2</bpmn:outgoing></bpmn:userTask><bpmn:endEvent id=\"end\"><bpmn:incoming>flow2</bpmn:incoming></bpmn:endEvent><bpmn:sequenceFlow id=\"flow1\" sourceRef=\"start\" targetRef=\"review\"/><bpmn:sequenceFlow id=\"flow2\" sourceRef=\"review\" targetRef=\"end\"/></bpmn:process></bpmn:definitions>"
}'

Response:

{
"data": {
"id": "def_01j...",
"key": "expense-approval",
"version": 1,
"name": "Expense Approval",
"source_type": "bpmn",
"source_hash": "c6e8a1b3...",
"is_active": true,
"created_at": "2026-04-26T12:00:00.000Z"
}
}

BPMN (base64) Format​

If you're loading a BPMN file from disk or transmitting it over a JSON-only channel, use source_type: "bpmn-base64". The API base64-decodes the source string to UTF-8 XML before parsing — the result is identical to sending source_type: "bpmn" directly. The stored source_type in the response will be "bpmn" (the decoded form).

To encode a file: base64 expense-approval.bpmn

Request:

curl -X POST https://api.stateway.io/v1/definitions \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"key": "expense-approval",
"name": "Expense Approval",
"source_type": "bpmn-base64",
"source": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48YnBtbjpkZWZpbml0aW9ucy..."
}'

Response:

{
"data": {
"id": "def_01j...",
"key": "expense-approval",
"version": 1,
"name": "Expense Approval",
"source_type": "bpmn",
"source_hash": "c6e8a1b3...",
"is_active": true,
"created_at": "2026-04-26T12:00:00.000Z"
}
}

JSON/YAML Element Reference​

Each element in the elements array supports these fields:

FieldTypeRequiredDescription
idstring✅Unique identifier within the process
typestring✅Element type (see below)
namestring—Human-readable label
outgoingstring[]—IDs of outgoing flows
timerTypedate | duration | cycle—For timerEvent elements
timerExpressionstring—ISO 8601 date, duration, or cycle expression
assignmentDefinitionobject—For userTask — assignment details: assignee (string, supports an expression — see Task Assignment for the full grammar), candidateGroups (string[], each item may be an expression, resolving to a CSV string fans out into multiple groups), candidateUsers (string[], same expression/CSV support)
taskDefinitionobject—For serviceTask / businessRuleTask
taskHeadersobject—Key-value headers for service tasks
ioMappingobject—For serviceTask / sendTask / businessRuleTask / userTask / intermediateCatchEvent / boundaryEvent — input/output variable mapping (on a userTask, see Human Tasks). On startEvent, outputs only. On the process start event they are the instance input contract: only the mapped targets are written to the instance, and the other start variables are discarded. On a subProcess start event they are added to the instance variables. Other element types reject it at publish time (HTTP 400)
formSchemaobject—For userTask — JSON Schema for the task form, rendered by the Frontend Gateway. In JSON/YAML you declare it explicitly; there is no auto-generation from types (see below).

Typed I/O & Form Schemas (BPMN XML only)​

BPMN 2.0 XML lets you attach a typed I/O specification to an activity — named, typed inputs and outputs (ioSpecification with dataInput/dataOutput, whose types come from itemDefinition declarations). JSON and YAML definitions express a subset of the process model and cannot declare this typed contract. Two behaviors that depend on it are therefore unavailable when a process is authored in JSON/YAML:

  • Form auto-generation. In BPMN, a userTask with a typed I/O specification gets its form generated automatically from the declared inputs and their types. In JSON/YAML there is no typed I/O specification, so you must declare the form explicitly with the formSchema field on the userTask element.
  • Typed output validation. In BPMN, the outputs submitted when a task or service task completes are validated against the activity's declared outputSets and types. Activities authored in JSON/YAML have no typed contract, so their outputs are accepted without type validation.

This is a documented limitation, not a bug: JSON/YAML are convenience formats for the same execution model, and they offer a subset of what BPMN XML can express — never a superset. If you need typed inputs/outputs, enumerations, or output validation, author the process (or at least the typed activities) in BPMN 2.0 XML. See Types & Validation for the full typed-modeling guide.

Supported Element Types​

For the full, always up-to-date list of every element, field, and its support status, see the Supported Elements & Fields reference.

TypeDescription
startEventProcess entry point
endEventEnds a path. A signal end event broadcasts its signal, a message end event throws a named event to its own instance, and a terminate end event ends every other path of its scope (see Events and Signals)
taskGeneric/abstract task — completes immediately and continues to the next flow (no side effects)
userTaskHuman task requiring manual action
serviceTaskAutomated HTTP call (sync or async)
sendTaskFire-and-forget HTTP call — the token advances immediately; once the response arrives in the background, the task's ioMapping.outputs are applied to it and merged into the instance's variables (same mapping semantics as a service task). The response is still recorded, and is subject to the same 5 MB cap as a service task: a larger response is discarded and the call is recorded as a failure — even though the request itself was delivered. The process continues either way
businessRuleTaskEvaluates a Decision Model (DMM)
exclusiveGatewayXOR — exactly one outgoing branch taken
parallelGatewayAND — all outgoing branches taken, waits for all to complete
inclusiveGatewayOR-split — evaluates conditions, can take multiple paths simultaneously; when no condition matches, routes to the default flow (the BPMN default attribute, or in JSON/YAML the first outgoing flow with no condition)
subProcessEmbedded sub-process — dispatches the nested flow it declares and only completes when that flow converges (see note below)
callActivityCalls another process definition — starts it as a separate process instance and only completes when that instance finishes (see note below)
timerEventPauses until a time condition is met (as startEvent or intermediateCatchEvent; see caveat below)
intermediateThrowEventEmits a notification:published event (delivered via webhook and WebSocket), including the variables named in variableFilter when present. With a signal it also broadcasts the signal; with an event name (messageRef or stateway:eventCorrelation) it also throws that event to its own instance (see Throwing Named Events from a Process)
boundaryEventAttached to an activity (task, userTask, serviceTask, scriptTask, businessRuleTask, sendTask, receiveTask, subProcess, or callActivity — rejected at publish time otherwise, see caveat below). Error boundary events catch an output-schema ValidationError from the attached businessRuleTask or serviceTask and route to an alternate flow. Timer boundary events arm when the host enters waiting and fire on schedule, interrupting the host or opening a parallel branch per cancelActivity (see Timers and caveat below). Message boundary events arm the same way and fire when a named event (stateway:eventCorrelation, or a native messageEventDefinition with a messageRef) occurs (see Events and Signals). Signal boundary events arm the same way and fire when a matching POST /events/signal/:signal_name broadcast arrives (see Signal Boundary Events). Escalation boundary events are parsed but never trigger
caution

intermediateCatchEvent fires for Timer, for any catch event that declares an event name through the stateway:eventCorrelation extension (eventName) or a native messageEventDefinition with a messageRef (the message's name is the event name; the extension wins if both are present), and for any catch event that declares a signal (signalRef, matched against Broadcast Signals): it waits for a named event or signal with that name (see Events and Signals). A catch event with none of eventName, signalRef, or a timer (a Message catch event with no messageRef, or a Conditional catch event) is recognized but passes through immediately (no correlation mechanism without a name — see Unsupported Element Types).

Boundary events fire for Error, Timer, Message, and Signal. A boundary attached to an activity that never enters waiting (task, scriptTask, businessRuleTask, sendTask — all synchronous) is accepted at publish time but never fires; the engine logs a warning when the host completes without the boundary ever arming. A Timer boundary with timeDate/timeDuration fires once, same as the standalone timerEvent — this is a single point in time, not a repeatable occurrence. A non-interrupting boundary (cancelActivity: false) behaves differently by type: a Timer timeCycle (R<n>/...) re-arms with the count decremented each time it fires, stopping after its last occurrence (an unbounded R/... never stops on its own); a Message or Signal boundary re-arms unconditionally and reacts again to every further occurrence while the host stays active — neither BPMN type has a repeat count. An interrupting boundary never re-arms, since firing it kills the host. A Message boundary only reacts to a named event recorded after it most recently armed (see Events and Signals), and a Signal boundary has no replay at all — a broadcast that arrives before it (re-)armed is simply missed (see No replay of signals emitted before the boundary armed). Escalation boundary events are parsed but never trigger — there is no correlation/dispatch mechanism for them yet. Planned, not yet supported.

A boundaryEvent is rejected at publish time if it declares no attachedToRef, if attachedToRef does not match any element in the process, if the host is not one of the activity types listed above, if it has no outgoing sequence flow, or if any sequence flow targets it directly (a boundary is not a normal flow node and cannot be entered that way).

Breaking change: subProcess now executes its nested flow

An embedded subProcess used to complete immediately, as if its nested flow didn't exist. It now opens the nested flow on entry and only completes once it converges — a token sitting on a userTask, serviceTask, or any other waiting element inside it now actually blocks the sub-process, the same way it would at the top level. Variables are read and written directly against the instance (no copy in/copy out) — a nested step can read a variable the parent set, and its own output becomes visible to the parent once the sub-process converges. A definition published before this change, with no nested flow ever parsed, keeps completing immediately for backward compatibility (logging a warning) — republish it to get real execution.

Every subProcess in a newly published definition must declare exactly one child startEvent; a subProcess with zero or more than one is rejected at publish time, with an error naming the element.

Breaking change: callActivity now calls the process it names

A callActivity used to complete immediately, as if the process it named didn't exist. It now starts that process as a separate process instance and only completes when that instance finishes. Four things change for you:

calledElement must resolve when you publish. The key it names has to be an already-published definition of yours — publish the called process first. A calledElement that names nothing, or that names the definition being published, is rejected with an error naming the element.

The version is pinned at publish time. Whichever version of the called process is active when you publish the calling definition is the version it will call from then on. Publishing a new version of the called process does not move callers; republish the caller to move it.

Data travels by explicit copy, in both directions. Unlike an embedded subProcess, which shares the instance's variables, a callActivity is a separate instance with its own variables. Only what you declare in its ioSpecification crosses: each dataInput is copied into the new instance on entry (from the dataInputAssociation when you declare one, otherwise from the caller variable of the same name), and each dataOutput is copied back out of the called instance's final variables when it finishes. A callActivity with no ioSpecification passes nothing in and brings nothing back.

Child instances are hidden from GET /v1/instances by default. An instance started by a callActivity is a detail of the caller's execution, so the list now returns only root instances. Pass include_children=true to see all of them, or parent_instance_id=<id> to list the instances one caller spawned. Each instance also carries parentInstanceId in the response.

A definition published before this change keeps completing immediately for backward compatibility (logging a warning) — republish it to get real execution.

When the called instance ends badly. If it ends in error, or someone terminates it through the API, the caller by default routes to the callActivity's error boundary event, or fails if there is none — this is what BPMN 2.0 does with an error that nothing catches. To decide otherwise in the diagram, declare onChildFailure:

<bpmn:callActivity id="call_kyc" calledElement="kyc-verification">
<bpmn:extensionElements>
<stateway:callConfig onChildFailure="continue" />
</bpmn:extensionElements>
</bpmn:callActivity>

With continue, the callActivity completes anyway and publishes <elementId>__childOutcome — an object with status, instance_id and error — into the caller's variables, so a gateway can branch on it. Its declared dataOutputs are not collected in this case: the called instance failed, so they would not be there. The default is propagate.

Interrupting the callActivity terminates the instance it created, recursively: an interrupting boundary event on it, cancelling its token, or terminating the calling instance all take the called instance down with them.

Unsupported Element Types (yet)​

The following BPMN 2.0 element types are not yet fully supported. They fall into three groups: a type that is rejected at publish time fails the request with an error naming the element; a recognized but unhandled type passes through immediately without executing any action; an unrecognized type is silently skipped during parsing, and the element together with its outgoing flows is excluded from the process model.

Rejected at publish time:

TypePurposeNotes
adHocSubProcessGroup tasks an operator may run in any order, or skipRejected: an ad-hoc sub-process declares no sequence flows defining execution order, so there is nothing for the engine to run. Model the work as an embedded subProcess with explicit sequence flows.

Recognized but not executed:

TypePurposeNotes
scriptTaskExecute an inline script (Groovy, JavaScript, etc.)Completes immediately without executing any script
receiveTaskWait for an inbound message before continuingCompletes immediately without waiting
eventBasedGatewayBranch based on whichever event arrives firstPasses through

Not recognized (silently skipped during parsing):

TypePurposeNotes
messageFlowMessage-based communication between participantsNot supported
laneSet / laneVisual swimlane organization of elementsNot supported
compensationEventTrigger compensating actions on rollbackNot supported
escalationEventEscalate to a parent process scopeNot supported
linkEventJump between points in the same process (go-to)Not supported
complexGatewayMerge/split based on custom boolean conditionsNot supported
multiInstanceTaskExecute a task N times (loop / parallel fan-out)Not supported
Available in BPMN XML only

Data objects and data stores are a BPMN 2.0 XML feature. To declare typed process variables or query data from other process instances directly in the BPMN diagram, see Types & Validation. These constructs (itemDefinition, dataObject, dataStoreReference) are parsed only from BPMN XML — JSON/YAML definitions express a subset and cannot declare them.

Expressions in Conditions​

Gateway flows can use the {{expression}} syntax in their condition field:

{
"flows": [
{ "id": "f-high", "sourceRef": "gw1", "targetRef": "high-value", "condition": "{{variables.amount > 10000}}" },
{ "id": "f-std", "sourceRef": "gw1", "targetRef": "standard", "condition": "{{variables.amount <= 10000}}" }
]
}

Format differences:

  • BPMN XML: Conditions are placed on bpmn:sequenceFlow elements using bpmn:conditionExpression. Gateways support a default="flowId" attribute to specify the fallback flow when no condition matches.
  • JSON/YAML: Use the condition field on flow objects. There is no default flow attribute equivalent in JSON/YAML format.

Both formats evaluate conditions using the same underlying mechanism.

Available context: variables, instance, session (who started the instance through the Frontend Gateway; empty for instances started with POST /v1/instances), now(). Operators: ==, !=, >, <, >=, <=, &&, ||, !

Listing Definitions​

Request:

curl https://api.stateway.io/v1/definitions \
-H "X-API-Key: sw_live_your_key"

Response:

{
"data": [
{
"id": "def_01j...",
"key": "expense-approval",
"version": 2,
"name": "Expense Approval",
"source_type": "json",
"source_hash": "a3f8c2d1...",
"is_active": true,
"created_at": "2026-04-26T12:00:00.000Z"
}
]
}

Versioning​

Every PUT to an existing definition creates a new version. Previous versions remain accessible. GET /definitions/:key always returns the currently active version.

List All Versions​

Request:

curl https://api.stateway.io/v1/definitions/expense-approval/versions \
-H "X-API-Key: sw_live_your_key"

Response:

{
"data": [
{
"id": "def_01j...",
"key": "expense-approval",
"version": 1,
"name": "Expense Approval",
"source_type": "json",
"source_hash": "a3f8c2d1...",
"is_active": false,
"created_at": "2026-04-26T12:00:00.000Z"
},
{
"id": "def_02k...",
"key": "expense-approval",
"version": 2,
"name": "Expense Approval v2",
"source_type": "json",
"source_hash": "b9d7f3e5...",
"is_active": true,
"created_at": "2026-05-01T09:00:00.000Z"
}
]
}

Get a Specific Version​

Request:

curl https://api.stateway.io/v1/definitions/expense-approval/versions/1 \
-H "X-API-Key: sw_live_your_key"

Response:

{
"data": {
"id": "def_01j...",
"key": "expense-approval",
"version": 1,
"name": "Expense Approval",
"source_type": "json",
"source_hash": "a3f8c2d1...",
"is_active": false,
"created_at": "2026-04-26T12:00:00.000Z"
}
}

Create a New Version​

Request:

curl -X PUT https://api.stateway.io/v1/definitions/expense-approval \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Expense Approval v2",
"source_type": "json",
"source": {
"key": "expense-approval",
"elements": [
{ "id": "start", "type": "startEvent", "outgoing": ["flow1"] },
{ "id": "review", "type": "userTask", "name": "Review Expense", "outgoing": ["flow2"] },
{ "id": "approve", "type": "serviceTask", "name": "Notify Finance", "outgoing": ["flow3"] },
{ "id": "end", "type": "endEvent" }
],
"flows": [
{ "id": "flow1", "sourceRef": "start", "targetRef": "review" },
{ "id": "flow2", "sourceRef": "review", "targetRef": "approve" },
{ "id": "flow3", "sourceRef": "approve", "targetRef": "end" }
]
}
}'

Response:

{
"data": {
"id": "def_02k...",
"key": "expense-approval",
"version": 2,
"name": "Expense Approval v2",
"source_type": "json",
"source_hash": "b9d7f3e5...",
"is_active": true,
"created_at": "2026-05-01T09:00:00.000Z"
}
}

New instances use the latest active version. Existing instances continue running on the version they were started with.

Rollback to a Previous Version​

Atomically reactivate a previous version, deactivating the current one:

Request:

curl -X POST https://api.stateway.io/v1/definitions/expense-approval/rollback \
-H "X-API-Key: sw_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "version": 1 }'

Response:

{
"data": {
"id": "def_01j...",
"key": "expense-approval",
"version": 1,
"is_active": true,
"updated_at": "2026-05-10T08:00:00.000Z"
}
}

After rollback, new instances will use version 1. The next PUT (new version) will be assigned MAX(version) + 1 — in this case version 3 — to avoid collision with existing version numbers.

Instances that were already running are not affected — they remain bound to the definition_id they were started with.

tip

See Process Versioning for a full guide on version management strategies.

Exporting as BPMN XML​

Export any definition back to BPMN XML (regardless of original format):

Request:

curl https://api.stateway.io/v1/definitions/expense-approval/xml \
-H "X-API-Key: sw_live_your_key" \
-o expense-approval.bpmn

The response has Content-Type: application/xml with the BPMN 2.0 XML document.

Auto-Layout​

Add ?layout=true to automatically inject diagram coordinates using bpmn-auto-layout. The result can be opened immediately in bpmn.io or Camunda Modeler without any manual positioning:

Request:

curl "https://api.stateway.io/v1/definitions/expense-approval/xml?layout=true" \
-H "X-API-Key: sw_live_your_key" \
-o expense-approval.bpmn

To export a specific version:

Request:

curl "https://api.stateway.io/v1/definitions/expense-approval/xml?version=1" \
-H "X-API-Key: sw_live_your_key" \
-o expense-approval-v1.bpmn

Mermaid Preview​

Generate a Mermaid flowchart diagram from a definition — useful for documentation, sharing, and visual review:

Request:

curl https://api.stateway.io/v1/definitions/expense-approval/preview.mermaid \
-H "X-API-Key: sw_live_your_key"

The response is text/plain Mermaid syntax:

flowchart TD
start([start]) --> flow1
flow1 --> review[Review Expense]
review --> flow2
flow2 --> end([end])

To preview a specific version:

Request:

curl "https://api.stateway.io/v1/definitions/expense-approval/preview.mermaid?version=2" \
-H "X-API-Key: sw_live_your_key"
info

The Mermaid output is for visualization only — the BPMN (or JSON/YAML) definition remains the source of truth. It is not re-imported.

Deleting a Definition​

Request:

curl -X DELETE https://api.stateway.io/v1/definitions/expense-approval \
-H "X-API-Key: sw_live_your_key"

Returns 204 No Content. This is a soft delete — running instances continue to execute on their original version. The definition key can be reused by creating a new definition with the same key.

Best Practices​

  • Use descriptive keys — expense-approval not proc1
  • Use meaningful element IDs — review-expense not node3
  • Add names to all elements — improves readability in API responses, logs, and task lists
  • Start with JSON/YAML — easier to version-control and author programmatically; export to BPMN when visual editing is needed
  • Use ?layout=true on first export — saves time positioning elements in bpmn.io or Camunda Modeler
  • Version deliberately — each version is immutable; use rollback to revert rather than deleting versions