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
| Field | Type | Required | Notes |
|---|---|---|---|
| workflow_id | string | yes | Stable snake_case id used everywhere else — event bindings, direct run_workflow calls, the dashboard. e.g. order_invoice_on_payment. |
| display_name | string | yes | Human-readable name shown in the dashboard. |
| description | string | no | What the workflow does. |
| version | string | no | Free-form version string. |
| status | string | yes | Draft, Active, Disabled, or Archived — see below. |
| enabled | boolean | no | Set false to pause a workflow without changing status. Defaults to true. |
| input_schema | json | no | {field: {type, required}} — validated before the first step runs. A missing required field fails the run before any step (and any side effect) fires. |
| steps | json | yes | The ordered list of step objects — see "The six step types" below. |
| on_error | string | no | Default 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_retries | integer | no | Retries per step when on_error is retry. Default 3. |
| retry_delay_seconds | integer | no | Delay between retries, in seconds. Default 2. |
| timeout_seconds | integer | no | Overall time budget for the whole run. |
| tags | string | no | Comma-separated, for organizing workflows in the dashboard. |
status — the four states
| status | Meaning |
|---|---|
| Draft | Saved 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. |
| Active | Runs normally, whether triggered directly or by an event binding. This is the only state that actually executes steps. |
| Disabled | Registered but blocked from running — same effect as enabled: false. |
| Archived | Retired; 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.
| type | Purpose | Key fields |
|---|---|---|
| service_call | Call an operation on another imported service — email, sms, slack, billing, and the rest of the catalog. | service, operation, input_map, on_error |
| crud_operation | Create, read, update, or delete a record in your own data. | operation (create/read/update/delete), object_type, record_uuid, data |
| condition | Branch on a resolved value. | condition, then_steps, else_steps |
| parallel | Run a list of sub-steps concurrently; each is tracked individually in the run's results. | steps (a sub-step array) |
| transform | Pure data reshaping between steps — no external call. | output_map |
| loop | Run 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.
| Pattern | Resolves to | Example |
|---|---|---|
| {{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 workflow | acme |
| {{context.timestamp}} | Current ISO-8601 timestamp | 2026-07-18T10:30:00Z |
| {{context.workflow_id}} | The running workflow's own id | order_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
| Event | Fires when |
|---|---|
| @create:ns:type | A new record of that schema is created |
| @update:ns:type | An existing record of that schema is written |
| @delete:ns:type | A record of that schema is deleted |
| @signup | A 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.kind | What it means |
|---|---|
automatic | The platform runs a known inverse operation. Used where the forward operation has a defined opposite. |
manual | A person has to intervene. The run records what needs undoing rather than undoing it. |
skip_acknowledged | There is genuinely nothing to undo, and you are saying so deliberately. Requires a reason. This is the canonical spelling — skip is rejected. |
none | No 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 operation | Inverse |
|---|---|
create_subscription | cancel_subscription |
create_checkout_session | expire_session |
create_customer | delete_customer |
archive_attachment | unarchive_attachment |
adjust_quantity | adjust_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 field | Meaning |
|---|---|
| status | running, 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_failed | Counts, including branch and parallel sub-steps. |
| current_step | The id of the currently running (or last executed) step. |
| error_message | Populated on the first failure. |
| output_data | Per-step outputs keyed by step_id. |
| step_results | Per-step status, output, and duration_ms, keyed by step_id. |
| duration_ms, started_at, completed_at | Timing for the whole run. |
| workflow_step_log field | Meaning |
|---|---|
| status | running, completed, failed, or skipped. |
| step_type, service_id, operation_id | What the step was and, for service_call, what it targeted. |
| input_sent / output_received | Exact resolved input and the raw output for that step. |
| duration_ms, retry_count | Timing 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:
| service | operation | Key input_map fields |
|---|---|---|
| send_email | to_email, subject, body_html or body_text, from_name | |
| sms | send_sms | to_number, body |
| send_message | to_number, body | |
| push_notification | send_notification | device_token, title, body |
| slack | send_message | channel, text |
| billing | create_invoice | customer_uuid, amount, currency, due_date |
| stripe_checkout | create_checkout_session | success_url, amount, product_name |
| salesforce | create_lead | last_name, company, email |
| google_calendar | create_event | summary, start_time, end_time, add_meet |
| auth_otp | send_otp | channel, 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
- •Workflow Engine Reference — the exhaustive step, template and saga reference behind this page.
- •Anatomy of a Supero App — where setup.py's workflow_definitions and event_bindings fit in the app bundle.
- •Transactional Services — the managed services catalog workflows call into, and how services get imported and configured.
- •Managing Data — the full CRUD API workflows read and write against.
- •API Integrations — bringing external services into the catalog that a workflow can call.
- •Workflow Orchestration — a worked end-to-end orchestration pattern.
On this page