Developers/Workflows/Workflow Engine Reference (Steps, Templates, Sagas)
Workflow Engine Reference (Steps, Templates, Sagas)
The full reference for the Workflow Engine service: step types, template syntax, error handling, and saga compensation for orchestrating multi-step business logic across your imported services.
Overview
The Workflow Engine (service_id: workflows, namespace: wf) orchestrates multi-step business logic across the other services you've imported into your project — email, SMS, Slack, billing, CRUD writes, and more. Instead of writing orchestration code, you declare a workflow as a JSON step list, store it as a CRUD record, and run it by ID. Each run creates a workflow_instance record with a full per-step audit trail.
This page is the reference for developers writing or reviewing workflow definitions by hand — step schemas, template syntax, retry/error semantics, and saga (rollback) compensation. If you're looking for how to auto-trigger a workflow when a record is created or updated, see Workflows & Automation; this page covers the engine itself.
The workflows service contract has three CRUD schemas and three operations:
| Schema | Prefix | Purpose |
|---|---|---|
| wf:workflow_definition | wfdef | Reusable workflow template — trigger, steps, input schema, error strategy. |
| wf:workflow_instance | wfinst | One record per execution. Tracks status, timing, and step results. |
| wf:workflow_step_log | wflog | One record per step per execution — input sent, output received, duration, retries. |
| Operation | Description |
|---|---|
| run_workflow | Executes a workflow by ID (or an inline step list). Validates input, runs all steps, returns the combined result. |
| validate_workflow | Validates a workflow definition — or an inline step list — without executing it. Checks step types, on_error values, and required fields per step type. |
| get_workflow_status | Defined in the operation contract for status lookups by instance_uuid. For inspecting a run in practice, read the wf:workflow_instance and wf:workflow_step_log records directly via CRUD — see "Inspecting workflow runs" below. |
ℹ️ Importing the service
Like any tenant service, add the Workflow Engine to your project from the services catalog (GET /api/v1/services/catalog, POST /api/v1/services/import) before calling it. Once imported, it's preconfigured — you don't need to set connection details yourself.
Running a workflow over the API
Workflows run through the same generic dispatcher as every other service: POST /api/v1/services/execute with service_id set to workflows and operation set to run_workflow. The important detail is nesting: the workflow's own input fields go inside an input object, which is itself the value of the outer request's input field.
bash
curl -X POST https://api.supero.dev/api/v1/services/execute \
-H "X-API-Key: $SUPERO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"service_id": "workflows",
"operation": "run_workflow",
"domain": "acme-corp",
"input": {
"workflow_id": "order_confirmed",
"input": {
"order_uuid": "8f2b1c40-91aa-4e3d-9c2b-8a6e9d6e2f10",
"customer_email": "[email protected]",
"customer_name": "Jane Doe",
"order_total": 129.00
}
}
}'The response wraps the workflow engine's result in the standard service-execute envelope: success, service_id, operation, and output holding the workflow_instance-shaped result.
json
{
"success": true,
"service_id": "workflows",
"operation": "run_workflow",
"output": {
"instance_uuid": "9c1e2a30-...",
"workflow_id": "order_confirmed",
"status": "completed",
"steps_completed": 3,
"steps_total": 3,
"steps_failed": 0,
"current_step": "update_status",
"duration_ms": 640,
"started_at": "2026-07-18T10:30:00Z",
"completed_at": "2026-07-18T10:30:01Z",
"error_message": "",
"trigger_source": "api",
"input_data": { "order_uuid": "8f2b1c40-...", "customer_email": "[email protected]" },
"output_data": {
"send_confirmation": { "message_id": "msg_123" }
},
"step_results": {
"send_confirmation": {
"status": "completed",
"output": { "message_id": "msg_123" },
"duration_ms": 420,
"retry_count": 0
}
}
}
}Python SDK
pip install supero exposes ServiceLib, a thin client purpose-built for calling services (including workflows) from your app's backend code. It reads its connection details from environment variables (SUPERO_URL, SUPERO_API_KEY, SUPERO_DOMAIN).
python
from supero import ServiceLib
sl = ServiceLib.from_env()
# Direct invocation — you already know the workflow_id.
# Keyword args become the workflow's "input" object.
result = sl.run_workflow(
"order_confirmed",
order_uuid=order.uuid,
customer_email=order.customer_email,
customer_name=order.customer_name,
order_total=order.total,
)
# Equivalent, via the typed accessor:
result = sl.workflow.run("order_confirmed",
order_uuid=order.uuid, customer_email=order.customer_email)
# Validate a definition without running it:
sl.workflow.validate("order_confirmed")result is the parsed {success, output, ...} envelope shown above, or None on a transport/auth failure — run_workflow() never raises.
JavaScript SDK
javascript
const org = await supero.login({ domainName: 'acme-corp', email, password });
const result = await org.services.workflows.run({
workflowId: 'order_confirmed',
input: {
orderUuid: order.uuid,
customerEmail: order.customerEmail,
customerName: order.customerName,
orderTotal: order.total,
},
});
await org.services.workflows.validate({ workflowId: 'order_confirmed' });
// Generic escape hatch, identical wire shape:
await org.services.exec('workflows', 'run_workflow', {
workflow_id: 'order_confirmed',
input: { orderUuid: order.uuid },
});⚠️ Nest under input
The most common mistake is putting workflow fields at the top level of the request instead of inside the nested input object. A flat body like {"workflow_id": "...", "order_uuid": "..."} resolves order_uuid to nothing inside the workflow — every {{input.order_uuid}} template renders empty.
Workflow definition fields
A workflow definition is a CRUD record of type wf:workflow_definition. Create one with a POST to /api/v1/crud/{domain}/wf:workflow_definition, or through the Workflows admin panel.
| Field | Type | Required | Description |
|---|---|---|---|
| workflow_id | string | Yes | Unique identifier, snake_case (e.g. order_confirmed, user_signup). |
| display_name | string | Yes | Human-readable name. |
| description | string | No | What this workflow does. |
| version | string | No | Workflow version (semver). |
| input_schema | json | No | Object mapping input field name to {type, required} — validated (warn, not fail-fast) when the workflow runs. |
| steps | json | Yes | Array of step objects — see Step types below. |
| on_error | string | No | Default error strategy for every step that doesn't set its own: stop (default), continue, retry, or compensate. See Error handling below. |
| max_retries | integer | No | Default max retries per step when on_error=retry. Default: 3. |
| retry_delay_seconds | integer | No | Delay between retries in seconds. Default: 2. |
| timeout_seconds | integer | No | Max execution time for the entire workflow run. |
| enabled | boolean | No | Whether this workflow is active. |
| tags | string | No | Comma-separated tags for categorization. |
| status | string | Yes | Draft, Active, Disabled, or Archived. |
⚠️ status gates execution
run_workflow refuses to execute a definition whose status is Draft, Disabled, or Archived (or whose enabled field is false) — the call returns a failed result explaining why. Set status to Active before triggering it in production.
json
{
"workflow_id": "order_confirmed",
"display_name": "Order confirmation flow",
"description": "Charge payment, send confirmation, notify team",
"version": "1.0.0",
"enabled": true,
"input_schema": {
"order_uuid": {"type": "string", "required": true},
"customer_email": {"type": "string", "required": true},
"customer_name": {"type": "string", "required": true},
"order_total": {"type": "float", "required": true}
},
"steps": [ /* ... */ ],
"on_error": "stop",
"status": "Active"
}Step types
Every step has an id, a type, and an optional on_error override. There are six step types.
1. service_call — call another imported service
The most common step. Calls another tenant service's operation through the platform's execute path. input_map field names must exactly match the field names that service's operation expects (the same names you'd send to POST /api/v1/services/execute directly — not the SDK's camelCase convenience names).
json
{
"id": "send_welcome_email",
"type": "service_call",
"service": "email",
"operation": "send_email",
"input_map": {
"to_email": "{{input.email}}",
"subject": "Welcome to {{input.company_name}}!",
"body_html": "<p>Hi {{input.name}}, your account is ready.</p>"
},
"on_error": "stop"
}| Service | Operation | input_map fields |
|---|---|---|
| email / email_smtp | send_email | to_email, subject, body_html or body_text, from_name |
| sms | send_sms | to_number, body |
| send_message | to_number, body | |
| send_template | to_number, template_name, template_params | |
| push_notification | send_notification | device_token, title, body |
| push_notification | send_topic_broadcast | topic, title, body |
| slack | send_message | channel, text |
| slack | create_channel | channel_name, topic |
| billing | create_customer | email, company_name |
| billing | create_invoice | customer_uuid, amount, currency, due_date |
| stripe_checkout | create_checkout_session | success_url, amount, product_name |
| stripe_checkout | create_payment_link | price_id |
| salesforce | create_lead | last_name, company, email |
| servicenow | create_incident | short_description, urgency, impact |
| github | create_issue | repo_owner, repo_name, title, labels |
| amazon_s3 | generate_upload_url | key, content_type |
| google_calendar | create_event | summary, start_time, end_time, add_meet |
| google_drive | create_folder | file_name, description |
| auth_otp | send_otp | channel, recipient, purpose |
| ai | chat_completion | messages (array of {role, content}) |
2. crud_operation — create, read, update, or delete a record
Modifies application data as part of the workflow: update a status field, create a related record, or read data needed by a later step. object_type must be namespace-qualified (e.g. myapp:order) — a bare type name 404s. update, read, and delete require record_uuid.
json
{
"id": "update_order_status",
"type": "crud_operation",
"operation": "update",
"object_type": "myapp:order",
"record_uuid": "{{input.order_uuid}}",
"data": {
"notification_status": "sent",
"notified_at": "{{context.timestamp}}"
}
}3. condition — branch based on data
Evaluates a resolved template against an expression and takes then_steps or else_steps. Supported operators: ==, !=, in, not in, >, <, >=, <=. Conditions can nest (the engine recursively executes branch steps, including nested conditions).
json
{
"id": "check_plan_type",
"type": "condition",
"condition": "{{input.plan}} == enterprise",
"then_steps": [ /* ... */ ],
"else_steps": [ /* ... */ ]
}4. parallel — run steps concurrently
Runs its sub-steps concurrently (bounded by the service's max_parallel_steps setting) and tracks each sub-step individually in the instance's step_results and completion counters.
json
{
"id": "notify_all_channels",
"type": "parallel",
"steps": [
{"id": "email_notify", "type": "service_call", "service": "email", "operation": "send_email", "input_map": { "to_email": "{{input.email}}", "subject": "Update", "body_html": "..." }, "on_error": "continue"},
{"id": "sms_notify", "type": "service_call", "service": "sms", "operation": "send_sms", "input_map": { "to_number": "{{input.phone}}", "body": "..." }, "on_error": "continue"}
]
}5. transform — reshape data between steps
Pure data mapping, no external calls. Use it to reshape an earlier step's output into the field names a later step's input_map expects.
json
{
"id": "prepare_invoice_data",
"type": "transform",
"output_map": {
"customer_email": "{{steps.get_customer.output.email}}",
"invoice_amount": "{{steps.calculate_total.output.total}}"
}
}6. loop — repeat a body of steps
Runs a body of sub-steps repeatedly until an exit condition holds or a ceiling is reached. This is the step that makes agent-style, multi-pass flows possible.
json
{
"id": "agent",
"type": "loop",
"max_iterations": 5,
"until": "{{steps.think.output.has_tool_calls}} == false",
"steps": [ /* body — runs sequentially, each sub-step sees the previous output */ ]
}| Field | Required | Notes |
|---|---|---|
| steps | yes | The body. Runs sequentially; each sub-step sees the previous one's output. |
| until | no | Exit condition, evaluated AFTER each iteration. Omit it and the loop runs exactly max_iterations times. |
| max_iterations | no | Defaults to 10, clamped to a hard ceiling of 100. |
⚠️ until is evaluated after each pass, so the body always runs at least once
A loop is do-while, not while. This is deliberate: an exit condition that reads a step's output cannot be evaluated before that step has run. If you need a loop that may run zero times, wrap it in a condition step.
Inside the body, {{loop.iteration}} resolves 1-based and {{loop.index}} 0-based. Sub-step outputs land in the context under their own id with the latest pass winning, so until and later steps read the most recent values — while per-iteration results are recorded separately, so a later pass does not erase an earlier one from the audit trail. A loop ends for one of four reasons: until_satisfied, max_iterations, step_failed, or workflow_timeout.
💡 Validate before you rely on a definition
Call validate_workflow (see "Running a workflow over the API" above) to catch unknown step types, invalid on_error values, and missing required fields per step type before the workflow is triggered in production.
Template syntax
Step fields (input_map values, condition expressions, crud_operation data/record_uuid, transform output_map) are resolved against an execution context using {{...}} templates. A value that is ENTIRELY one template expression resolves to the underlying type (an object, number, or boolean stays that type); a template embedded inside a longer string is stringified and substituted.
| Pattern | Resolves to | Example |
|---|---|---|
| {{input.field}} | A field from the workflow's input object | {{input.email}} |
| {{steps.<id>.output.field}} | A field from a named step's output | {{steps.create_order.output.order_number}} |
| {{steps.<id>.status}} | The status of a named step | {{steps.send_email.status}} |
| {{context.domain}} | The current domain name | acme-corp |
| {{context.timestamp}} | The workflow run's start time (ISO 8601) | 2026-07-18T10:30:00Z |
| {{context.workflow_id}} | The workflow_id being executed | order_confirmed |
| {{context.request_id}} | The request trace ID | req_abc123 |
A template that resolves to nothing (a missing key, or a step that hasn't run yet) renders as an empty string in partial substitutions, or null when the whole field is one template — this is deliberate: a missing input is visible in the output rather than silently skipping the step.
Error handling and retries
Every step resolves an on_error strategy: its own on_error if set, otherwise the workflow's top-level on_error, otherwise stop. There are four values.
| Strategy | Behavior |
|---|---|
| stop (default) | Halt the workflow immediately on this step's failure. Overall status becomes failed. |
| continue | Log the error and proceed to the next step. If any step failed this way, the overall run ends with status partial rather than completed. |
| retry | Retry the failed step up to max_retries times, waiting retry_delay_seconds between attempts. If every attempt fails, the step is treated as if on_error were stop. |
| compensate | Halt like stop, then walk back through every step that already completed and run its declared compensate block to undo it — a saga rollback. See Saga compensation below. |
⚠️ condition and transform never retry
Both step types are pure evaluation with no external call, so retry is skipped for them even if on_error=retry is set — they either succeed or fail deterministically on the first attempt.
Recommended defaults: payment/financial steps use stop or compensate (never continue — you don't want to silently swallow a failed charge); notification steps (email/SMS/Slack) use continue, since a bounced notification shouldn't block the rest of the workflow; external calls prone to transient failure use retry; parallel sub-steps generally use continue so one channel failing doesn't block the others.
Saga compensation
When a workflow's on_error is compensate, a step failure doesn't just stop the run — the engine walks back through every step that already completed, in reverse completion order, and runs each one's compensate block to undo it. Use compensate whenever a step mutates state outside your database (a Stripe customer, a QuickBooks invoice, a Slack message, a GitHub issue) that a later failure could orphan.
🚨 Every compensable step needs its own compensate block
Setting on_error=compensate at the workflow level is not enough by itself. Every service_call and crud_operation step in that workflow must carry its own inline compensate block. A step with no compensate block halts the entire rollback walk at that point — everything before it in the completion order is left un-compensated.
The compensate block
Add a compensate object directly on the forward step. kind selects the walker's behavior:
| kind | Meaning | Walker behavior |
|---|---|---|
| automatic | An inverse operation is declared inline. | Calls service/operation with the resolved input_map. |
| skip_acknowledged | Nothing to undo, and that's expected (e.g. an email that was already sent). | Logs a skip and continues walking back through earlier steps. |
| none | This step is itself an inverse / terminal operation. | Never reached by the walker in v1 usage. |
| manual | The undo requires a decision only an operator can make. | The walker halts the compensation walk; the run ends with status compensation_failed for operator review. |
For kind=automatic, the compensate object needs service, operation, and input_map (or, for a crud_operation-shaped compensate, operation: create|update|delete, object_type, and data). For manual and skip_acknowledged, include a reason string explaining the decision — it's surfaced to whoever reviews the run.
Compensate template syntax
compensate.input_map uses the same {{...}} syntax as forward steps, plus two extra prefixes scoped to the forward step being undone:
| Pattern | Resolves to |
|---|---|
| {{step.input.X}} | Field X from the forward step's own resolved input_map |
| {{step.output.Y}} | Field Y from the forward step's output |
| {{steps.<id>.output.X}} | Output of any other step in the run (same as forward-step templates) |
| {{input.X}} | The workflow's top-level input |
⚠️ No arithmetic in templates
If a forward step's numeric field needs to be negated for the inverse call (a classic case: an inventory delta), the compensate block must hardcode the literal negated value at authoring time — the template engine can't compute -X from {{step.input.X}}.
Worked example
A subscription signup that creates a billing customer, then a subscription. If the second step fails, the walker undoes the first. Note the different kind on each step: deleting a freshly-created customer is safe to automate, but canceling a subscription doesn't refund the current billing period, so that decision is left to an operator.
json
{
"workflow_id": "customer_subscribe",
"on_error": "compensate",
"steps": [
{
"id": "create_customer",
"type": "service_call",
"service": "billing",
"operation": "create_customer",
"input_map": { "email": "{{input.email}}", "company_name": "{{input.company_name}}" },
"compensate": {
"kind": "automatic",
"service": "billing",
"operation": "delete_customer",
"input_map": { "customer_id": "{{step.output.customer_id}}" }
}
},
{
"id": "create_subscription",
"type": "service_call",
"service": "billing",
"operation": "create_subscription",
"input_map": {
"customer_id": "{{steps.create_customer.output.customer_id}}",
"plan": "{{input.plan}}"
},
"compensate": {
"kind": "manual",
"reason": "cancel_subscription stops future billing but does not refund the current period — an operator must decide whether a refund is owed before canceling."
}
}
]
}If create_subscription fails, the walker only has create_customer to undo (create_subscription itself never completed, so it's not in the completion order). It runs billing.delete_customer with the customer_id captured from create_customer's own output. Had create_subscription succeeded and a later step in the same workflow failed, the walker would instead halt on create_subscription's manual compensate and leave the run in compensation_failed for review — because a subscription's inverse touches money and can't be safely automated.
Money-touching services deserve extra care here: prefer manual over automatic for anything where the inverse operation doesn't fully undo the financial effect of the forward operation (a paid invoice can't simply be voided; a canceled subscription doesn't refund the current period).
Inspecting workflow runs
Every run is fully queryable through CRUD, in the same way as your own application data. Read wf:workflow_instance for the run-level summary and wf:workflow_step_log for the per-step audit trail.
http
# All failed runs of any workflow
GET /api/v1/crud/{domain}/wf:workflow_instance?status=failed
# Step-by-step trail for one run
GET /api/v1/crud/{domain}/wf:workflow_step_log?instance_uuid={instance_uuid}| status (workflow_instance) | Meaning |
|---|---|
| completed | Every step succeeded. |
| partial | on_error=continue and at least one step failed, but the run finished. |
| failed | The run halted on a step failure (on_error=stop or retry exhaustion), and compensation either wasn't configured or wasn't needed. |
| compensated | The run halted, on_error=compensate ran, and every completed step was successfully rolled back. |
| compensation_failed | The run halted and compensation itself hit a step with no compensate block, a manual kind, or a failed inverse call. Needs operator review. |
wf:workflow_instance also carries compensation_started_at, compensation_completed_at, failed_step_id, compensation_errors (a list of {step_id, error} for any inverse call that itself failed), and completion_order (the ordered list of step_ids that completed — the same order the compensation walker reverses).
wf:workflow_step_log records carry phase (forward for normal execution, compensation for rollback steps) and, for compensation entries, compensates_step_id pointing back at the forward step_id being undone — plus input_sent, output_received, duration_ms, retry_count, and status (running, completed, failed, or skipped) for every individual step.
Next steps
- •CRUD Endpoints — the full REST reference for reading and writing the records your workflow steps operate on.
- •Plugins API — the secondary integration path for wiring a custom HTTP endpoint into a service_call step.
- •Python SDK — the full pip install supero client reference, including ServiceLib and the CRUD manager.
- •JavaScript SDK — the full SDK reference, including the org.services accessor used in the examples above.
On this page