S
supero.docs
Documentation/Building Your App/Workflows & Automation

Workflows & Automation

Chain email, SMS, billing calls, and CRUD writes into declarative, auditable multi-step flows that run on their own — triggered directly, or fired automatically when your data changes.

What a workflow is

A workflow is a declarative, auditable chain of steps — call another service, write a CRUD record, branch on a condition, run things in parallel, reshape data — described as configuration rather than code. There is no separate workflow engine to deploy: workflows is a tenant service like email or slack, service_id workflows, namespace wf. Import it the same way you import any other managed service (config.py's services list, with "workflows" listed last), and every run it executes, and every step within that run, is stored as an ordinary CRUD record you can query.
A workflow definition is itself a CRUD record of type wf:workflow_definition. In practice you author it as a plain dict passed to AppSetup(...).run(workflow_definitions=[...]) in setup.py — see Anatomy of a Supero App for where that fits in the bundle.
python
{
    "workflow_id": "order_invoice_on_payment",
    "display_name": "Order Invoice on Payment",
    "description": "Emails an invoice once an order is marked paid, then stamps the order.",
    "version": "1.0.0",
    "status": "Active",
    "enabled": True,
    "on_error": "continue",
    "input_schema": {
        "order_uuid": {"type": "string", "required": True},
        "payment_state": {"type": "string", "required": True},
    },
    "steps": [ /* see "The six step types" below */ ],
}
Once a workflow is registered and Active, you can run it directly with one call:
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",
    "input": {
      "workflow_id": "order_invoice_on_payment",
      "input": {"order_uuid": "3f9c1e2a-6b7d-4e11-9c2f-2a6f1d0b9a10", "payment_state": "paid"}
    }
  }'

ℹ️ Two levels of "input"

The outer input is the run_workflow operation's own payload — {workflow_id, input}. The inner input is what your workflow's steps see as {{input.*}}. It is easy to misplace one level of nesting; the shape above is exact.

Anatomy of a workflow definition

FieldTypeRequiredNotes
workflow_idstringyesStable snake_case id used everywhere else — event bindings, direct run_workflow calls, the dashboard. e.g. order_invoice_on_payment.
display_namestringyesHuman-readable name shown in the dashboard.
descriptionstringnoWhat the workflow does.
versionstringnoFree-form version string.
statusstringyesDraft, Active, Disabled, or Archived — see below.
enabledbooleannoSet false to pause a workflow without changing status. Defaults to true.
input_schemajsonno{field: {type, required}} — validated before the first step runs. A missing required field fails the run before any step (and any side effect) fires.
stepsjsonyesThe ordered list of step objects — see "The six step types" below.
on_errorstringnoDefault error strategy for steps that don't set their own: stop (default), continue, retry, or compensate. compensate turns the workflow into a saga — see Saga Compensation below.
max_retriesintegernoRetries per step when on_error is retry. Default 3.
retry_delay_secondsintegernoDelay between retries, in seconds. Default 2.
timeout_secondsintegernoOverall time budget for the whole run.
tagsstringnoComma-separated, for organizing workflows in the dashboard.

status — the four states

statusMeaning
DraftSaved but not runnable. A direct run_workflow call and an event binding both fail with an explicit "in Draft status" error rather than silently no-op'ing.
ActiveRuns normally, whether triggered directly or by an event binding. This is the only state that actually executes steps.
DisabledRegistered but blocked from running — same effect as enabled: false.
ArchivedRetired; blocked from running, kept for history.

⚠️ A workflow must be Active + enabled to run

status must be exactly "Active" and enabled must not be explicitly false. Everything else — Draft, Disabled, Archived, or enabled: false — is rejected up front, whether the workflow was invoked directly or fired by an event binding.

💡 Validate before you activate

Call the same /api/v1/services/execute endpoint with operation "validate_workflow" (input: {workflow_id}) to check that referenced services/operations exist and the step graph is well-formed, without running any step or causing any side effect.

The six step types

The table below is the working summary of each step type and the fields you reach for most. For the exhaustive field-by-field reference — every step type, the template resolver, workflow templates, and saga ordering — see the Workflow Engine Reference.
typePurposeKey fields
service_callCall an operation on another imported service — email, sms, slack, billing, and the rest of the catalog.service, operation, input_map, on_error
crud_operationCreate, read, update, or delete a record in your own data.operation (create/read/update/delete), object_type, record_uuid, data
conditionBranch on a resolved value.condition, then_steps, else_steps
parallelRun a list of sub-steps concurrently; each is tracked individually in the run's results.steps (a sub-step array)
transformPure data reshaping between steps — no external call.output_map
loopRun a body of sub-steps repeatedly until a condition holds or a ceiling is reached. This is the step that makes agent-style flows possible.steps (a sub-step array), until, max_iterations

loop

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 */ ]
}
The body runs sequentially and each sub-step sees the previous one's output. 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 iteration winning, so until and any later step read the most recent pass — while per-iteration results are also recorded separately, so a later iteration does not erase an earlier one from the audit trail. A run ends for one of four reasons: until_satisfied, max_iterations, step_failed, or workflow_timeout.

⚠️ until is evaluated after each iteration, 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. Omit until entirely and the loop runs exactly max_iterations times. max_iterations defaults to 10 and is clamped to a hard ceiling of 100, so a runaway loop stops rather than burning your whole time budget.
Every step object also carries an id (used to reference its output from later steps) and an optional on_error that overrides the workflow-level default for that one step.

crud_operation

json
{
  "id": "mark_paid",
  "type": "crud_operation",
  "operation": "update",
  "object_type": "acmestore:order",
  "record_uuid": "{{input.order_uuid}}",
  "data": {"workflow_status": "processed", "processed_at": "{{context.timestamp}}"}
}

⚠️ object_type must be namespace-qualified

Use ns:type (e.g. acmestore:order), not a bare type name — an unqualified object_type 404s against the platform's CRUD API.

condition

json
{
  "id": "check_paid",
  "type": "condition",
  "condition": "{{input.payment_state}} == paid",
  "then_steps": [ /* only run when the condition is true */ ],
  "else_steps": [ ]
}
Supported operators: ==, !=, in, not in, >, <, >=, <=. Conditions can nest — then_steps and else_steps can themselves contain condition steps.

parallel

json
{
  "id": "notify_all_channels",
  "type": "parallel",
  "steps": [
    {"id": "email_notify", "type": "service_call", "service": "email", "operation": "send_email", "on_error": "continue", "input_map": {"to_email": "{{input.email}}", "subject": "Shipped!", "body_html": "<p>Your order is on its way.</p>"}},
    {"id": "sms_notify", "type": "service_call", "service": "sms", "operation": "send_sms", "on_error": "continue", "input_map": {"to_number": "{{input.phone}}", "body": "Your order has shipped."}}
  ]
}

transform

json
{
  "id": "prepare_invoice_data",
  "type": "transform",
  "output_map": {
    "customer_email": "{{steps.get_customer.output.email}}",
    "invoice_amount": "{{steps.calculate_total.output.total}}"
  }
}

Wiring data between steps

Every string field on a step — input_map values, condition, object_type, data — is resolved through the same {{...}} template syntax before the step runs. A value that is a full {{...}} match preserves its original type (an object stays an object); embedded inside a longer string it interpolates.
PatternResolves toExample
{{input.field}}A field from the workflow's own input{{input.email}}
{{steps.<id>.output.field}}A field from a prior step's output{{steps.create_order.output.order_number}}
{{steps.<id>.status}}A prior step's status (completed/failed/skipped){{steps.send_email.status}}
{{context.domain}}The domain running the workflowacme
{{context.timestamp}}Current ISO-8601 timestamp2026-07-18T10:30:00Z
{{context.workflow_id}}The running workflow's own idorder_invoice_on_payment

Triggering a workflow

A workflow runs in one of two ways: you call it directly (as in the Overview example above), or you bind it to a data event and the platform fires it automatically. Automatic firing is the common case for transactional automation — you never call anything to make it happen.

Event bindings

EventFires when
@create:ns:typeA new record of that schema is created
@update:ns:typeAn existing record of that schema is written
@delete:ns:typeA record of that schema is deleted
@signupA new tenant_user completes signup through the app's own login page
json
{
  "event": "@signup",
  "workflow_id": "welcome_on_signup",
  "input_map": {"email": "user.email", "name": "user.full_name"}
}
input_map values are dotted paths resolved against the event's payload — for a @create/@update/@delete event that payload is the record itself, so a bare "uuid" is the new record's own uuid, not a nested field. For @signup the payload nests the account under user, hence user.email / user.full_name above. A path that doesn't resolve is simply omitted from the workflow's input rather than failing the trigger — the run still fires with partial input.

Reference hydration — multi-hop paths

When a path's first segment names a reference field rather than a plain field, the platform fetches the referenced record and keeps resolving the rest of the path against it. This chains across multiple hops. For example, an order_item carries a reference to its parent order; order in turn references customer:
json
{
  "event": "@create:acmestore:order_item",
  "workflow_id": "item_added_notification",
  "input_map": {
    "product_name": "product_name",
    "customer_email": "order.customer.email"
  }
}
product_name resolves as a plain top-level field on the new order_item. order.customer.email does not: order is a reference, so the platform fetches the referenced order record, finds customer is itself a reference on that record, fetches the customer record, and reads email off it — two hops resolved automatically from one dotted path.

Registering bindings

Pass event_bindings=[...] to AppSetup(...).run() in setup.py alongside workflow_definitions — this is the normal path and keeps both in source control. Bindings live in the workflows service's own config, so you can also write them directly with PUT /api/v1/services/workflows/config, body {"config_data": {"event_bindings": [...]}} — useful for adjusting a binding without a redeploy.

Worked example — transactional email

Two workflows that cover the most common transactional-email pattern: a one-shot welcome on signup, and a conditional invoice email that only fires once an order is actually paid.

welcome_on_signup — fires on @signup

python
{
    "workflow_id": "welcome_on_signup",
    "display_name": "Welcome Email",
    "status": "Active", "enabled": True, "on_error": "continue",
    "input_schema": {
        "email": {"type": "string", "required": True},
        "name": {"type": "string", "required": False},
    },
    "steps": [
        {"id": "email", "type": "service_call", "service": "email", "operation": "send_email",
         "on_error": "continue",
         "input_map": {
             "to_email": "{{input.email}}",
             "subject": "Welcome to Acme Store!",
             "body_html": "<p>Hi {{input.name}}, thanks for joining — your account is ready.</p>"}},
    ],
}

order_invoice_on_payment — fires on @update:acmestore:order

python
{
    "workflow_id": "order_invoice_on_payment",
    "display_name": "Order Invoice on Payment",
    "status": "Active", "enabled": True, "on_error": "continue",
    "input_schema": {
        "order_uuid": {"type": "string", "required": True},
        "payment_state": {"type": "string", "required": True},
    },
    "steps": [
        {"id": "check_paid", "type": "condition",
         "condition": "{{input.payment_state}} == paid",
         "then_steps": [
             {"id": "email", "type": "service_call", "service": "email", "operation": "send_email",
              "on_error": "continue",
              "input_map": {
                  "to_email": "{{input.customer_email}}",
                  "subject": "Your Acme Store invoice",
                  "body_html": "<p>Thanks for your payment — order {{input.order_number}} is confirmed.</p>"}},
             # Final step MUST stamp workflow_status so the UI status chip updates.
             {"id": "mark_processed", "type": "crud_operation", "operation": "update",
              "object_type": "acmestore:order", "record_uuid": "{{input.order_uuid}}",
              "data": {"workflow_status": "processed", "processed_at": "{{context.timestamp}}"}},
         ],
         "else_steps": []},
    ],
}
The matching bindings:
json
[
  {"event": "@signup", "workflow_id": "welcome_on_signup",
   "input_map": {"email": "user.email", "name": "user.full_name"}},
  {"event": "@update:acmestore:order", "workflow_id": "order_invoice_on_payment",
   "input_map": {"order_uuid": "uuid", "order_number": "order_number",
                 "customer_email": "customer_email", "payment_state": "payment_state"}}
]

💡 send_email's input_map fields

to_email, subject, and body_html (or body_text) are required; from_name is optional. These exact names apply whichever event or step triggers the email — use them for any service_call step targeting service: "email".

ℹ️ End effectful workflows with a CRUD stamp

A final crud_operation that sets a workflow_status field (e.g. "processed") on the record that triggered the run is the standard pattern — it lets your UI show a status chip by reading the record itself, instead of polling the workflow engine.

Saga Compensation — Undoing a Half-Finished Run

The strategies above all answer "what should happen next?" when a step fails. They do not answer the harder question: a payment was captured, then the fulfilment step failed — what undoes the capture? That is what the fourth on_error value is for.
Setting on_error: "compensate" on a workflow turns it into a saga. The engine records each successful step, and on failure walks the completed steps backwards, running the inverse of each one. This is the mechanism behind the "Saga" badge you may see on a marketplace listing.

⚠️ compensate makes a compensate block mandatory on every step

Two layers apply here and they differ, which is worth knowing before you debug one. The generation-time validator requires every service_call and crud_operation step in a compensate workflow to carry its own compensate block, and fails the workflow if one is missing — steps with nothing to undo declare that explicitly with kind: "skip_acknowledged" and a reason. At runtime the compensation walker is more forgiving: if a step has no inline block but its service manifest declares an inverse operation, the walker uses that. Around 99 operations across 42 services declare one. condition, parallel, transform and loop steps never need a block of their own.
compensate.kindWhat it means
automaticThe platform runs a known inverse operation. Used where the forward operation has a defined opposite.
manualA person has to intervene. The run records what needs undoing rather than undoing it.
skip_acknowledgedThere is genuinely nothing to undo, and you are saying so deliberately. Requires a reason. This is the canonical spelling — skip is rejected.
noneNo compensation. Reserve it for steps with no external effect.
For automatic compensation the platform keeps a catalog of inverse operations, so common forward operations already know how to reverse themselves.
Forward operationInverse
create_subscriptioncancel_subscription
create_checkout_sessionexpire_session
create_customerdelete_customer
archive_attachmentunarchive_attachment
adjust_quantityadjust_quantity — self-inverse, applied with the opposite delta
python
{
    "id": "charge_customer",
    "type": "service_call",
    "service": "stripe_checkout",
    "operation": "create_checkout_session",
    "input_map": {"customer_uuid": "{{input.customer_uuid}}", "amount": "{{input.total}}"},
    "compensate": {
        "kind": "automatic",
        "operation": "expire_session",
        "input_map": {"session_id": "{{steps.charge_customer.output.session_id}}"},
    },
}
python
{
    "id": "send_confirmation",
    "type": "service_call",
    "service": "email",
    "operation": "send_email",
    "input_map": {"to_email": "{{input.email}}", "subject": "Order confirmed"},
    "compensate": {
        "kind": "skip_acknowledged",
        "reason": "An email that has already been delivered cannot be recalled; \
                   a correction email is sent by the failure handler instead.",
    },
}

💡 Money steps deserve a second look

Compensate blocks on billing, payment, stripe_checkout, billing_paypal, billing_razorpay, billing_phonepe, plaid and quickbooks steps are the ones worth reviewing by hand. An inverse that silently does nothing on a money step is the most expensive defect this feature can hide.

⚠️ A compensate block is a single step, not a branch

A compensate block may not contain then, on_failure, then_steps or else_steps — the compensation walker does not run nested control flow. If undoing a step needs conditional logic, model it as a separate manual compensation and handle the branch outside the saga.
For the full step-level reference, including how the walker orders compensation across parallel branches, see the Workflow Engine Reference.

Checking a run

Every run is a wf:workflow_instance CRUD record, and every step within it is a wf:workflow_step_log CRUD record — read them the same way you'd read any other data, rather than polling a dedicated status endpoint.
bash
curl -X POST https://api.supero.dev/api/v1/crud/acme/query \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "wf:workflow_instance",
    "filters": {"workflow_id": "order_invoice_on_payment"},
    "sort": [{"field": "started_at", "order": "desc"}],
    "limit": 10
  }'
python
runs = org.crud.query("wf:workflow_instance") \
    .filter(workflow_id="order_invoice_on_payment") \
    .order_by("-started_at").limit(10).all()

steps = org.crud.query("wf:workflow_step_log") \
    .filter(instance_uuid=runs[0]["uuid"]) \
    .order_by("step_index").all()
workflow_instance fieldMeaning
statusrunning, completed, failed, or partial (some steps succeeded, some failed under on_error: continue). A saga adds two more: compensated, and compensation_failed. See the Workflow Engine Reference for the full set.
steps_completed / steps_total / steps_failedCounts, including branch and parallel sub-steps.
current_stepThe id of the currently running (or last executed) step.
error_messagePopulated on the first failure.
output_dataPer-step outputs keyed by step_id.
step_resultsPer-step status, output, and duration_ms, keyed by step_id.
duration_ms, started_at, completed_atTiming for the whole run.
workflow_step_log fieldMeaning
statusrunning, completed, failed, or skipped.
step_type, service_id, operation_idWhat the step was and, for service_call, what it targeted.
input_sent / output_receivedExact resolved input and the raw output for that step.
duration_ms, retry_countTiming and how many retries were attempted.

⚠️ A silent send is a configuration problem, not a workflow bug

A workflow only fires (and only produces a status) when it is Active + enabled. Given that, an effectful step like send_email still needs its target service actually imported and configured with working credentials — an unconfigured or uncredentialed service marks its own step failed (or, under on_error: continue, leaves the whole run partial) without blocking the rest of the workflow. Check Admin → Services in The Admin Panel for that service's credentials before assuming the workflow itself is broken.

💡 Simulation mode on self-hosted deployments

On self-hosted / on-premise installs, service_call and crud_operation steps only perform real side effects once the workflow service is configured with the platform's API URL. Until then, steps report a simulated success — useful for validating step wiring safely, but it means "the run completed" does not yet mean an email actually sent.

Services available to service_call steps

input_map field names for a service_call step must match the operation's expected input exactly. A representative slice of the catalog:
serviceoperationKey input_map fields
emailsend_emailto_email, subject, body_html or body_text, from_name
smssend_smsto_number, body
whatsappsend_messageto_number, body
push_notificationsend_notificationdevice_token, title, body
slacksend_messagechannel, text
billingcreate_invoicecustomer_uuid, amount, currency, due_date
stripe_checkoutcreate_checkout_sessionsuccess_url, amount, product_name
salesforcecreate_leadlast_name, company, email
google_calendarcreate_eventsummary, start_time, end_time, add_meet
auth_otpsend_otpchannel, recipient, purpose
Each service must be imported for the project — via config.py's services list, the dashboard's Services page, or the services catalog API — before a workflow step can call it. See Transactional Services for the full catalog and how services get imported, and Managed Integrations & Provider Catalog for the third-party providers behind them.

Next steps