Expression Syntax
This is the reference for integration engineers configuring workflow nodes. Every value you can configure on a node — a URL, a header, a body field, a mapping rule — can either be a static literal or an expression that pulls data from somewhere else (a previous step's output, a secret, an environment variable, the workflow's input).
Expressions are how data flows from one step to the next without you writing glue code. They look like this: {{secrets.API_KEY}} or {{steps.1.outputs.body}}. Everywhere this page talks about an expression, the syntax is double curly braces around a path.
${{ vars.X }} and ${{ nodes.id.outputs.port }} (referencing nodes by ID instead of by index). The current implementation uses the simpler index-based form documented here. The ID-based form is more robust against node reordering, and the platform team is evaluating switching to it. If you're building a workflow that will live for a long time, expect a migration path.Expression syntax, in one example
Expressions appear anywhere in a node's configuration:
{
"connector": "http-request",
"config": {
"url": "https://api.example.com/orders/{{order_id}}",
"headers": {
"Authorization": "Bearer {{secrets.API_KEY}}"
},
"body": {
"items": "{{steps.2.outputs}}"
}
}
}
Available Variables
Everything an expression can reach falls into one of a handful of namespaces, each with its own prefix. There's the workflow's input, the output of earlier steps, your secrets, and your environment variables. The rest of this section takes them one at a time; the short version is that the prefix tells you where a value comes from, and the path after it tells you which value.
Input Data: {{$}}
{{$}} is the data the workflow started with — whatever the trigger produced. A bare {{$}} hands the entire input through untouched, which is handy when you just want to forward a webhook's payload to another system:
{
"body": "{{$}}"
}
For a webhook trigger the input carries more than the body, so you can pull the headers separately when you need them — for example to inspect an authorization or signature header:
{
"body": "{{$}}",
"headers": "{{$.headers}}"
}
Field Access: {{$.field}}
More often you want one value out of the input rather than the whole thing. A dot path drills in:
{
"url": "https://api.example.com/customers/{{$.customer_id}}",
"email": "{{$.contact_email}}"
}
The same dotted notation keeps going for nested objects, so a shipping address buried a level deep is still a single expression:
{
"country": "{{$.shipping.country}}",
"postal_code": "{{$.shipping.postal_code}}"
}
Step Outputs: {{steps.N.outputs}}
This is the one that makes multi-step workflows work: any step can read what an earlier step produced, addressed by its index. Indexing is zero-based, so the trigger is step 0, the first node after it is step 1, and so on. Pull a single field when that's all you need —
{
"status": "{{steps.0.outputs.status}}"
}
{
"total": "{{steps.1.outputs.total}}"
}
— or take a step's entire output to pass along wholesale:
{
"data": "{{steps.0.outputs}}"
}
When a step returns an array, bracket notation reaches into it by position:
{
"first_item": "{{steps.0.outputs[0]}}"
}
Because steps are addressed by number, reordering nodes can change which output a given index refers to — one of the reasons the platform is weighing the ID-based syntax flagged in the warning above.
Secrets: {{secrets.KEY}}
Credentials never belong in a workflow definition, where they'd end up in version control and audit logs. Instead you store them as secrets and reference them by name; the real value is substituted only at runtime:
{
"headers": {
"Authorization": "Bearer {{secrets.ERP_API_KEY}}",
"X-API-Secret": "{{secrets.INTERNAL_SECRET}}"
}
}
This indirection is what keeps credentials safe: secrets are encrypted at rest in the database, are never written to logs or audit trails, and exist in plaintext only for the instant they're resolved during a run. You manage them per workflow in Cockpit — open Integration Studio → Workflows → Workflow, switch to the Secrets tab, add your key-value pairs, and from then on reference each one as {{secrets.KEY_NAME}}.
Environment Variables: {{env.VAR}}
Environment variables cover the values that change between deployments rather than between runs — which environment you're in, which region, which tenant. They're fixed when the workflow is deployed and read at the start of each run:
{
"url": "https://{{env.ENVIRONMENT}}.api.example.com/orders",
"timeout": "{{env.DEFAULT_TIMEOUT_MS}}"
}
A handful are always available: env.ENVIRONMENT (dev, staging, or production), env.TENANT_ID for the current tenant, and env.REGION for the deployment region (eu-west, us-east, and so on). They're ideal for building environment-aware URLs so the same definition runs unchanged from dev through production.
Resolution Order
Most of the time a prefix makes a reference unambiguous. But a bare name without a prefix has to be looked up somewhere, and the engine checks the namespaces in a fixed order, taking the first match it finds:
- Local (step) context:
item(in Iterator nodes) - Secrets:
secrets.* - Step outputs:
steps.N.outputs - Input data:
$.* - Environment:
env.*
The practical consequence is that more specific, run-local sources win over broader ones. If both a step output and an input field are called customer_id, the step output is the one you get. When two sources might collide, the safest move is to keep the prefix — {{steps.0.outputs.customer_id}} rather than a bare {{customer_id}} — so the reference says exactly what you mean.
Expression Examples
Seeing expressions in isolation only gets you so far; the next few examples show them doing real work across a whole workflow.
Example 1: Fetch Order Details
The first is the bread-and-butter pattern — receive an event, enrich it from another system, and write the result somewhere. A webhook delivers an order ID, an HTTP step fetches the full order from the ERP using that ID, and a final step posts a record assembled from both the original input and the ERP's response. Watch how {{$.order_id}} (from the trigger) and {{steps.1.outputs.*}} (from the ERP call) are combined in the last step's body:
{
"step_0": {
"connector": "webhook-trigger",
"config": { "path": "/orders/received" }
},
"step_1": {
"connector": "http-request",
"config": {
"method": "GET",
"url": "https://erp.example.com/api/v2/orders/{{$.order_id}}",
"headers": {
"Authorization": "Bearer {{secrets.ERP_API_KEY}}",
"X-Tenant": "{{env.TENANT_ID}}"
},
"timeoutMs": 30000
}
},
"step_2": {
"connector": "http-request",
"config": {
"method": "POST",
"url": "{{env.INTERNAL_API}}/orders",
"headers": {
"Authorization": "Bearer {{secrets.INTERNAL_API_TOKEN}}",
"Content-Type": "application/json"
},
"body": {
"id": "{{$.order_id}}",
"customer_id": "{{$.customer_id}}",
"erp_order_id": "{{steps.1.outputs.id}}",
"total": "{{steps.1.outputs.total}}",
"currency": "{{steps.1.outputs.currency}}",
"fetched_at": "{{steps.1.outputs.timestamp}}"
}
}
}
}
Example 2: Conditional Transform
Expressions also feed the conditions that control-flow nodes evaluate. Here a Switch inspects the incoming order's status and routes it down one of three paths — paid orders to processing, pending orders to a hold, everything else to rejection. The condition strings are just expressions that resolve to booleans:
{
"step_0": {
"connector": "webhook-trigger"
},
"step_1": {
"connector": "switch",
"config": {
"cases": [
{
"condition": "{{$.status == 'paid'}}",
"output": "process_payment"
},
{
"condition": "{{$.status == 'pending'}}",
"output": "hold_and_notify"
},
{
"default": true,
"output": "reject_order"
}
]
}
},
"step_2": {
"connector": "http-request",
"config": {
"url": "https://api.example.com/process",
"method": "POST",
"body": {
"order_id": "{{$.order_id}}",
"status": "{{steps.1.outputs}}",
"amount": "{{$.total}}"
}
}
}
}
Example 3: Loop and Email
Inside an Iterator, a different variable comes into play. The workflow below runs on a schedule, fetches the day's invoices as an array, then loops over them — and within the loop {{item}} refers to whichever invoice is currently being processed, so each email is addressed and filled from that one record:
{
"step_0": {
"connector": "schedule-trigger",
"config": {
"schedule": "0 18 * * MON-FRI"
}
},
"step_1": {
"connector": "http-request",
"config": {
"url": "https://api.example.com/daily-invoices",
"headers": {
"Authorization": "Bearer {{secrets.API_KEY}}"
}
}
},
"step_2": {
"connector": "iterator",
"config": {
"items": "{{steps.1.outputs.invoices}}"
}
},
"step_3": {
"connector": "send-email",
"config": {
"to": "{{item.customer_email}}",
"subject": "Invoice {{item.invoice_id}} Ready",
"body": "<p>Amount: {{item.total}}</p><p><a href='{{item.download_url}}'>Download</a></p>"
}
}
}
Inside the iterator (step 3), {{item}} refers to the current invoice from the invoices array.
Example 4: Environment-Specific API Keys
The last example combines env and secrets to keep one definition working across environments. The same workflow can resolve a different credential per environment by composing the secret name from an environment variable — so production reaches for the production key without any change to the definition itself:
{
"step_0": {
"connector": "webhook-trigger"
},
"step_1": {
"connector": "http-request",
"config": {
"url": "https://api.example.com/process",
"method": "POST",
"headers": {
"Authorization": "Bearer {{secrets.API_KEY}}",
"X-Environment": "{{env.ENVIRONMENT}}"
},
"body": "{{steps.0.outputs}}"
}
}
}
If env.ENVIRONMENT = 'production', it looks up secrets.production_API_KEY.
Data Types
An expression resolves to whatever type the underlying value has, and that type carries through to wherever you place it. A few cases are worth calling out because they trip people up.
When you embed an expression in surrounding text, the result is a string — this is how you build composite values like a full name from two fields:
{
"name": "{{$.first_name}} {{$.last_name}}"
}
A standalone expression keeps its numeric type, so a price stays a number rather than becoming a quoted string:
{
"amount": "{{$.price}}"
}
Boolean results are produced by comparison operators, but they're only meaningful where a node expects a condition — in practice, the control-flow nodes like Switch:
{
"condition": "{{$.lifetime_value > 10000}}"
}
And expressions can resolve to whole arrays or objects, which is what lets you pass a collection or a nested structure straight from one step to the next:
{
"items": "{{steps.0.outputs.items}}",
"metadata": "{{$.metadata}}"
}
Common Patterns
A few recurring shapes are worth knowing by name, since they come up in almost every non-trivial workflow.
When a field might be missing, give it a fallback so the workflow doesn't break on an empty value — the default filter supplies one:
{
"email": "{{$.email | default('noreply@example.com')}}"
}
To narrow an array, don't try to express the logic inline — expressions don't run arbitrary code. Use the dedicated Filter node, which evaluates its condition against each item:
{
"connector": "filter",
"config": {
"expression": "{{item.price > 100}}"
}
}
To build a string like a versioned URL, drop expressions inline and the surrounding literal text is preserved around them:
{
"url": "https://api.example.com/v{{$.api_version}}/orders"
}
And for timestamps, use the built-in helpers {{$now}} and {{$today}} rather than reaching for date arithmetic — they resolve to the current moment at run time:
{
"processed_at": "{{$now}}",
"order_date": "{{steps.0.outputs.created_at}}"
}
Error Handling
An expression can fail to resolve — most often because it points at a field that isn't there or a step index that doesn't exist. What happens next depends on the engine, and it mirrors the difference everywhere else in Integration Studio. On an async workflow the step fails and the engine retries it with backoff, so a transient cause may clear on its own. On a sync workflow there's no retry; the request comes back as a 400 with the error spelled out, for the caller to handle.
The error message names the step and the offending reference, which usually points straight at the problem:
{
"error": "Expression error in step_1: steps.2.outputs is undefined (step index out of range)"
}
The reliable way to avoid these is to test a workflow with representative sample data before deploying it — the canvas's Test run resolves every expression against real values and surfaces a bad reference long before production traffic does.
Next Steps
- Node Types — All available nodes and their inputs/outputs
- Triggers — How workflows start
- Workflow Builder — Building and testing workflows visually