Automation
Workflows

Automation Workflows

This guide walks through building real flows with the Automation engine: a flow is a graph of typed nodes, triggered by an event, that branches on conditions, enriches with lookups, and sends through your providers. Each example below is a complete, publishable flow.

Anatomy of a flow

A flow definition has a nodes array and a separate edges array — nodes hold the work, edges wire them together:

  • Nodes carry id, type, and a config object.
  • Edges carry source, target, and a type — success (or default), failure, or branch. Branch edges also carry a key that the routing node returns.
  • saveAs stores a node's output under a variable name.
  • {{ }} expressions read upstream data inside config values:
    • {{ input.payload.* }} — the triggering event
    • {{ vars.* }} — variables prior nodes saved
    • {{ nodes.<id>.output.* }} — a specific node's result

Drafts are editable; publications are immutable. Always simulate a draft (a full dry-run with no side effects) before publishing.

Example 1 — Welcome email on signup

When a customer is created, enrich the record and send a welcome email through your SendGrid account.

Linear workflowFlow: after start the customer is looked up, the template is rendered, the email is sent and the flow stops.lookup_customerrender_templatesend_emailLEGENDSTEPEND
Steps run in order and the flow stops where one fails — later steps do not run. That prevents a half-finished send from silently completing.
{
  "name": "Welcome email on signup",
  "version": "1",
  "enabled": true,
  "triggerType": "event",
  "nodes": [
    { "id": "start", "type": "start" },
    {
      "id": "lookup", "type": "lookup_customer",
      "config": { "customerId": "{{ input.payload.customerId }}", "saveAs": "customer" }
    },
    {
      "id": "tpl", "type": "render_template",
      "config": { "template": "Welcome, {{ vars.customer.firstName }}!", "saveAs": "body" }
    },
    {
      "id": "email", "type": "send_email",
      "config": {
        "provider": "sendgrid",
        "apiKeySecretRef": "vault:sg_prod",
        "to": "{{ vars.customer.email }}",
        "subject": "Welcome to Acme",
        "body": "{{ vars.body }}",
        "saveAs": "emailResult"
      }
    },
    { "id": "stop", "type": "stop" }
  ],
  "edges": [
    { "id": "e1", "source": "start",  "target": "lookup", "type": "success" },
    { "id": "e2", "source": "lookup", "target": "tpl",    "type": "success" },
    { "id": "e3", "source": "lookup", "target": "stop",   "type": "failure" },
    { "id": "e4", "source": "tpl",    "target": "email",  "type": "success" },
    { "id": "e5", "source": "email",  "target": "stop",   "type": "success" }
  ]
}

Example 2 — Branch on order value

When an order is paid, take a different path for high-value orders. The if node evaluates its first case and returns a branch key; the runner follows the branch edge whose key matches.

Conditional branchFlow: when the amount passes the threshold an SMS goes to the priority desk, otherwise the default branch sends a receipt email.key:truekey:defaultif (total > 50000)send_sms (VIP desk)send_email (receipt)LEGENDDECISIONOUTPUT
Anything that does not match falls to the default branch — the flow never ends silently because no branch matched. Do not leave the default empty.
{
  "nodes": [
    {
      "id": "branch", "type": "if",
      "config": {
        "cases": [
          { "field": "input.payload.order.total", "op": "gt", "value": 50000, "key": "true" }
        ],
        "defaultEdgeKey": "default"
      }
    }
  ],
  "edges": [
    { "id": "b1", "source": "branch", "target": "smsVip",       "type": "branch", "key": "true" },
    { "id": "b2", "source": "branch", "target": "emailReceipt", "type": "branch", "key": "default" }
  ]
}

In an if / switch case, field is a plain path into the run context (input.payload.order.total), not a {{ }} expression — the engine reads the value at that path. Operators: eq, neq, gt, gte, lt, lte, in.

For more than two branches, use switch (pick a branch edge by value) or rule (a nested all / any / none condition tree that routes to success or failure).

Example 3 — Enrich, validate, then act

Before sending, look up the payment, validate the resolved object, and only continue if the shape is correct.

{
  "nodes": [
    {
      "id": "pay", "type": "lookup_payment",
      "config": { "paymentId": "{{ input.payload.paymentId }}", "saveAs": "payment" }
    },
    {
      "id": "check", "type": "validate_schema",
      "config": {
        "target": "{{ vars.payment }}",
        "rules": [
          { "field": "status", "required": true, "enum": ["captured", "refunded"] },
          { "field": "amount", "required": true, "type": "number", "min": 1 }
        ]
      }
    }
  ],
  "edges": [
    { "id": "e1", "source": "pay",   "target": "check", "type": "success" },
    { "id": "e2", "source": "pay",   "target": "stop",  "type": "failure" },
    { "id": "e3", "source": "check", "target": "act",   "type": "success" },
    { "id": "e4", "source": "check", "target": "stop",  "type": "failure" }
  ]
}

Connecting your providers

The send_email, send_sms and send_invoice nodes deliver through your provider with your key. Store the credential in the vault first, then reference it by secretRef — never inline the raw key.

{
  "type": "send_email",
  "config": {
    "provider": "sendgrid",
    "apiKeySecretRef": "vault:sg_prod",
    "to": "{{ vars.customer.email }}",
    "templateId": "welcome_v2",
    "variables": { "name": "{{ vars.customer.firstName }}" }
  }
}
⚠️

Need a provider that isn't built in? Use generic_http_email or generic_http_sms and define the full request (URL, method, headers, body) yourself.

Build, simulate, publish

Create a draft

curl https://api-gateway.lunixi.com/api/v1/automation/flows/draft \
  -H "Authorization: Bearer at_live_5f3e2b1a..." \
  -H "Content-Type: application/json" \
  -d @welcome-flow.json

Validate the graph

curl https://api-gateway.lunixi.com/api/v1/automation/flows/validate \
  -H "Authorization: Bearer at_live_5f3e2b1a..." \
  -d @welcome-flow.json

Simulate (full dry-run, no side effects)

Sends, webhooks and HTTP calls are skipped and replaced with synthetic results; downstream nodes still resolve so you can inspect the whole path.

curl https://api-gateway.lunixi.com/api/v1/automation/flows/{id}/simulate \
  -H "Authorization: Bearer at_live_5f3e2b1a..." \
  -d '{ "input": { "payload": { "customerId": "cus_123" } } }'

Publish

Publishing compiles the draft into an immutable version. A checksum gate requires a fresh validate + simulate after any change.

curl https://api-gateway.lunixi.com/api/v1/automation/flows/{id}/publish \
  -H "Authorization: Bearer at_live_5f3e2b1a..." -d '{}'

Driving the flow from your storefront

Register an integration to get a signed ingress URL and an HMAC secret, then post events from your app. WordPress / WooCommerce events are normalised to canonical commerce.* keys automatically.

TS=$(date +%s)
BODY='{"event":"order.paid","data":{"order":{"id":"1042","total":52000}}}'
SIG=$(printf '%s.%s' "$TS" "$BODY" \
  | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex | sed 's/^.* //')
 
curl https://api-gateway.lunixi.com/api/v1/automation/integrations/wordpress/intg_123/events \
  -H "X-Automation-Signature: sha256=$SIG" \
  -H "X-Automation-Timestamp: $TS" \
  -H "Content-Type: application/json" \
  -d "$BODY"

The response status mirrors the outcome (202 accepted, 200 duplicate, 401 bad signature, 409 replayed nonce, 429 rate-limited), so producer-side retries behave correctly.

Governing manual runs

For flows that move money or message customers, gate manual runs through an approval queue. Set the merchant default policy, or override it per flow:

curl -X PUT https://api-gateway.lunixi.com/api/v1/automation/merchant-policy \
  -H "Authorization: Bearer at_live_5f3e2b1a..." \
  -d '{ "defaultManualRunPolicy": "approval" }'

With approval, a manual run enqueues a pending request instead of executing. A reviewer with the automation:approve scope decides:

curl https://api-gateway.lunixi.com/api/v1/automation/approvals/{id}/decide \
  -H "Authorization: Bearer at_live_5f3e2b1a..." \
  -d '{ "decision": "approve", "note": "Verified order total" }'

Approving runs the flow against its published version. The full request payload is sealed and used as the only run source; reviewers see a redacted projection.

Observing runs

Pull per-flow run history and drill into a single run's node-by-node trace:

# Recent runs for a flow
curl "https://api-gateway.lunixi.com/api/v1/automation/flows/{id}/runs?limit=50" \
  -H "Authorization: Bearer at_live_5f3e2b1a..."
 
# A single run's redacted trace
curl https://api-gateway.lunixi.com/api/v1/automation/flows/{id}/runs/{runId} \
  -H "Authorization: Bearer at_live_5f3e2b1a..."

You can also query the overview dashboard, the inbound trigger-event log, the outbound connector-delivery log, and the append-only audit trail. See Automation for the full set.