S
supero.docs
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:
SchemaPrefixPurpose
wf:workflow_definitionwfdefReusable workflow template — trigger, steps, input schema, error strategy.
wf:workflow_instancewfinstOne record per execution. Tracks status, timing, and step results.
wf:workflow_step_logwflogOne record per step per execution — input sent, output received, duration, retries.
OperationDescription
run_workflowExecutes a workflow by ID (or an inline step list). Validates input, runs all steps, returns the combined result.
validate_workflowValidates 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_statusDefined 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.
FieldTypeRequiredDescription
workflow_idstringYesUnique identifier, snake_case (e.g. order_confirmed, user_signup).
display_namestringYesHuman-readable name.
descriptionstringNoWhat this workflow does.
versionstringNoWorkflow version (semver).
input_schemajsonNoObject mapping input field name to {type, required} — validated (warn, not fail-fast) when the workflow runs.
stepsjsonYesArray of step objects — see Step types below.
on_errorstringNoDefault error strategy for every step that doesn't set its own: stop (default), continue, retry, or compensate. See Error handling below.
max_retriesintegerNoDefault max retries per step when on_error=retry. Default: 3.
retry_delay_secondsintegerNoDelay between retries in seconds. Default: 2.
timeout_secondsintegerNoMax execution time for the entire workflow run.
enabledbooleanNoWhether this workflow is active.
tagsstringNoComma-separated tags for categorization.
statusstringYesDraft, 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"
}
ServiceOperationinput_map fields
email / email_smtpsend_emailto_email, subject, body_html or body_text, from_name
smssend_smsto_number, body
whatsappsend_messageto_number, body
whatsappsend_templateto_number, template_name, template_params
push_notificationsend_notificationdevice_token, title, body
push_notificationsend_topic_broadcasttopic, title, body
slacksend_messagechannel, text
slackcreate_channelchannel_name, topic
billingcreate_customeremail, company_name
billingcreate_invoicecustomer_uuid, amount, currency, due_date
stripe_checkoutcreate_checkout_sessionsuccess_url, amount, product_name
stripe_checkoutcreate_payment_linkprice_id
salesforcecreate_leadlast_name, company, email
servicenowcreate_incidentshort_description, urgency, impact
githubcreate_issuerepo_owner, repo_name, title, labels
amazon_s3generate_upload_urlkey, content_type
google_calendarcreate_eventsummary, start_time, end_time, add_meet
google_drivecreate_folderfile_name, description
auth_otpsend_otpchannel, recipient, purpose
aichat_completionmessages (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 */ ]
}
FieldRequiredNotes
stepsyesThe body. Runs sequentially; each sub-step sees the previous one's output.
untilnoExit condition, evaluated AFTER each iteration. Omit it and the loop runs exactly max_iterations times.
max_iterationsnoDefaults 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.
PatternResolves toExample
{{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 nameacme-corp
{{context.timestamp}}The workflow run's start time (ISO 8601)2026-07-18T10:30:00Z
{{context.workflow_id}}The workflow_id being executedorder_confirmed
{{context.request_id}}The request trace IDreq_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.
StrategyBehavior
stop (default)Halt the workflow immediately on this step's failure. Overall status becomes failed.
continueLog the error and proceed to the next step. If any step failed this way, the overall run ends with status partial rather than completed.
retryRetry 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.
compensateHalt 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:
kindMeaningWalker behavior
automaticAn inverse operation is declared inline.Calls service/operation with the resolved input_map.
skip_acknowledgedNothing to undo, and that's expected (e.g. an email that was already sent).Logs a skip and continues walking back through earlier steps.
noneThis step is itself an inverse / terminal operation.Never reached by the walker in v1 usage.
manualThe 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:
PatternResolves 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
completedEvery step succeeded.
partialon_error=continue and at least one step failed, but the run finished.
failedThe run halted on a step failure (on_error=stop or retry exhaustion), and compensation either wasn't configured or wasn't needed.
compensatedThe run halted, on_error=compensate ran, and every completed step was successfully rolled back.
compensation_failedThe 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.