Conditions and variables

How workflow params and conditions get their values. Covers /#steps references, Handlebars templates and escaping, every condition operator and its casting rules, the changes diff, workflow variables for secrets, and error cases.

Every value in a workflow, whether it's a step param, a condition operand or a REST header, is one of four things:

FormExampleResult
Literal"done", 3, true, ["vip"]Used as is
Reference"/#steps.lookup.emails"The value at that path, with its type (array, number, object…)
Function"/#fn:parseFloat,steps.1.attributes.data.total"The function's result, typed
Template"Order {{{steps.1.custom.orderNumberStr}}} shipped"A string, rendered with Handlebars

The rule: a string that starts with /# is a reference or function call and keeps its type, but only when the reference is the whole string. Any other string is a template, so "Order /#steps.1.id" is sent as that literal text. To mix text and values, use {{ }}.

For a guided introduction to this language, see Data and variables. This page is the reference.

After resolution, each param is cast to the type in the action's input schema. A template that renders "42" for a number input becomes 42.

References

/# followed by a dotted path into the run's context:

PathWhat it is
steps.<id>.<field>A step's output. The trigger is step 1
steps.<id>.list.0.fieldAn array element, by index
steps.<id>.list.lengthAn array's length
variables.<variableId>A workflow variable, by its ID
error.<field>The current error, inside error cases

A path that doesn't exist resolves to undefined. It doesn't cause an error, so a typo in a step ID silently produces nothing. Check the run's logs if a value seems to be missing.

When a param's reference resolves to null or undefined, the param is left out of the request entirely. An empty object counts as "not set" too. The one exception is queue, where null is kept and means "unassign".

Path syntax

SyntaxExampleNotes
Dotssteps.lookup.custom.tierStrEach segment is an object key
Indexsteps.lookup.emails.0.email or steps.lookup.emails[0].emailWhole, non-negative numbers only. There are no negative indexes, so you can't count from the end
lengthsteps.1.tags.lengthWorks on arrays and strings
Quoted bracketssteps.call.body["order.id"]For a key that contains dots. Plain dots also work if that exact key exists

Only an object's own keys are read, so inherited properties such as constructor resolve to nothing.

References in params
{
  "id": "followup",
  "action": "kustomer.conversation.update",
  "params": {
    "id": "/#steps.1.id",
    "assignedUsers": ["/#steps.owner.id"],
    "custom": {
      "orderTotalNum": "/#fn:parseFloat,steps.order.data.total",
      "lastOrderAt": "/#steps.order.createdAt"
    }
  },
  "transitions": []
}

Templates

Strings without /# are Handlebars templates rendered against the same context, so {{steps.1.name}}, {{{variables.<variableId>}}} and the standard block helpers ({{#if}}, {{#each}}, {{#unless}}, {{#with}}) all work. Kustomer adds helpers for dates, HMAC signing, JSON and formatting.

Templates in params
{
  "body": "{{{steps.lookup.firstName}}} asked about order {{{steps.1.custom.orderNumberStr}}}.{{#if steps.1.custom.vipBool}} VIP customer.{{/if}}",
  "uri": "https://api.example.com/orders/{{{steps.1.custom.orderNumberStr}}}?expand=items"
}

A template always produces a string. To pass an array or object, use a reference instead: "tags": "/#steps.1.tags", not "tags": "{{steps.1.tags}}".

InteractiveHow a reference walks the run context
Run context click to flip
{
"steps": {
"1": {
"name": "Tom & Jerry's refund",
"status": ,
"priority": ,
"channels": ["email"],
"custom": {
"orderNumberStr": "4417",
"vipBool":
},
"changes": {
"attributes": {
"status": {
"op": "replace",
"before": "open",
"after":
}
}
}
},
"lookup": {
"firstName": "Jane",
"emails": [
{
"email": "jane@example.com"
}
]
}
}
}
References
Templates
Conditions
Resolves toarray
[{"email":"jane@example.com"}]

A reference keeps its type. This param gets the array itself, so use a reference, not a template, to pass arrays and objects.

A sample run context: step 1 is the trigger (a conversation update) and lookup is a later step's output. Only path lookups, {{#if}} and eq are evaluated here. Values marked ⇄ can be flipped.

Conditions

Conditions guard transitions. Each one is { "op": …, "values": […] }. Each value can be a literal, a reference or a template.

{ "op": "eq", "values": ["/#steps.1.changes.attributes.status.after", "done"] }

Operators

opIn the builderValuesPasses when
andMultiple Conditions (And)conditionsEvery nested condition passes
orMultiple Conditions (Or)conditionsAny nested condition passes
existsExists[a]a is truthy
dneDoes not exist[a]a is falsy ("does not exist")
truenot offered[a]a is true or the string "true". [true] makes a branch that always passes
eqEquals[a, b]a equals b, after casting b to a's type
neqNot equal[a, b]eq would fail
gt, gte, lt, lteGreater than, Greater than or equal, Less than, Less than or equal[a, b]Numeric comparison of Number(a) and Number(b)
gt-date, lt-dateAfter, Before[a, b]a is after or before b, both parsed as dates
containsContains[a, b]a is an array that includes b, or a string containing b (case-sensitive). If b is an array, every item must be contained
not-containsDoes not contain[a, b]a doesn't contain b. If b is an array, at least one item is missing

There are no in or not-in operators. To test "status is one of A or B", use an or group of eq conditions. An op that isn't in this table fails the run with operation <op> not supported.

The builder offers only the operators that suit the field you pick. Text, list, date-time and number fields all get Exists, Does not exist, Equals and Not equal. Text and list fields add Contains and Does not contain, date-time fields add Before and After, and number fields add the four numeric comparisons. Boolean fields get only Equals and Not equal. A path you type yourself in code view gets every operator. The builder never offers true; when a step's only transition is always-true, the canvas draws a plain line instead of a condition.

contains understands skills: on a conversation's skills array it matches the skill ID, and only for active skills.

Case-insensitive text checks

contains and eq are case-sensitive. Condition values can be templates as well as references, so render the left side in lowercase and compare it with a lowercase literal:

JSON
{ "op": "contains", "values": ["{{{toLower steps.1.subject}}}", "refund"] }

A template always renders a string, so use this only for text. For numbers and booleans, keep the plain reference so eq can cast.

Conditions on what changed

Update triggers fire for every change to the record, including changes your workflow makes. Base conditions on the changes diff, which describes only this update:

changes in a customer.update trigger
{
  "changes": {
    "attributes": {
      "custom": {
        "op": "replace",
        "before": { "tierStr": "silver", "loyaltyPointsNum": 1200 },
        "after": { "tierStr": "gold", "loyaltyPointsNum": 2400 }
      },
      "modifiedAt": { "op": "replace", "before": "2026-10-21T15:00:00.000Z", "after": "2026-10-21T15:20:42.554Z" }
    }
  }
}
You wantCondition
Status just became doneeq [/#steps.1.changes.attributes.status.after, "done"]
Status changed at allexists [/#steps.1.changes.attributes.status]
Became VIP (custom field)and of eq [/#steps.1.changes.attributes.custom.after.tierStr, "gold"] and neq [/#steps.1.changes.attributes.custom.before.tierStr, "gold"]
A tag was addedcontains [/#steps.1.changes.attributes.tags.after, "<tagId>"] and not-contains [/#steps.1.changes.attributes.tags.before, "<tagId>"]
Assigned to someone newexists [/#steps.1.changes.attributes.assignedUsers]

Custom attributes change as one custom entry whose before and after hold the changed custom fields, so the field name comes after before or after in the path. Tags, users and teams are stored as IDs, so compare with IDs, not names. The full diff format is in The event envelope.

Variables and secrets

Workflow variables hold values you don't want written into the workflow itself: API keys, signing secrets, or a URL that differs between your sandbox and production orgs. Each variable belongs to one workflow.

Shell
curl -X POST "https://acme.api.kustomerapp.com/v1/workflows/$WORKFLOW_ID/variables" \
  -H "Authorization: Bearer $KUSTOMER_API_KEY" -H "Content-Type: application/json" \
  -d '{ "name": "analyticsToken", "value": "sk_live_…", "type": "string" }'
Response (abridged)
{ "data": { "id": "6513b2f4a4b1e1001a6b3f20", "attributes": { "name": "analyticsToken" } } }
Variable
namestringrequired
valuestringrequired
typestring

Refer to a variable by its ID, the 24-character data.id returned when you create it, not by its name. Use /#variables.<variableId> as a whole param, or {{{variables.<variableId>}}} inside a template:

JSON
{ "headers": { "Authorization": "Bearer {{{variables.6513b2f4a4b1e1001a6b3f20}}}" } }

In the builder, the value picker's Variables section inserts /#variables.<variableId> for you, and JSON fields have a Variables button that copies {{{variables.<variableId>}}}. To find an ID later, list the workflow's variables with GET /v1/workflows/{id}/variables.

Variables are cached, so like workflow edits, a changed value can take about a minute to reach new runs. Manage them with the variables endpoints.

Handle errors

When a step fails, for example because a Kustomer action rejects its input with a 4xx, the step's errorCases decide what happens. Without a matching case, the run stops and the failure is recorded on the workflow. See Debugging.

The classic workflow builder has no editor for error cases. Add errorCases in Edit workflow JSON or with the API.

Continue past a validation error, stop on anything else
{
  "id": "create-order",
  "action": "kustomer.kobject.create-with-customer",
  "params": { "klassName": "order", "customer": "/#steps.1.customer", "title": "/#steps.1.custom.orderNumberStr", "data": {} },
  "errorCases": [
    {
      "condition": { "op": "and", "values": [{ "op": "eq", "values": ["/#error.status", 400] }] },
      "errorAction": { "type": "continue" }
    },
    {
      "condition": { "op": "and", "values": [] },
      "errorAction": { "type": "stop-processing" }
    }
  ],
  "transitions": [{ "target": "tag", "condition": { "op": "exists", "values": ["/#steps.create-order.id"] } }]
}
  • Cases are checked in order and the first match applies. A case whose condition.values is empty matches every error.
  • An error case's condition.op must be and or or, with up to five nested conditions, and a step can have up to 10 cases.
  • While cases are checked, the error is at /#error, never at /#steps.<id>.error. For Kustomer actions it's the first item of the API's errors array, with the fields status, code, title, detail and source.
errorAction.typeEffect
continueMove on to the step's transitions as if it had succeeded, but with no output. Guard the next transition with exists on the step's output, as above
stop-processingEnd the run quietly. It isn't recorded as a failure
retry-workflowBehaves exactly like continue. Nothing is retried
retry-actionNot handled. The step fails as if no case had matched

Use continue and stop-processing. The schema accepts retry-action and retry-workflow, but neither retries anything; branch explicitly instead.

App settings

Workflows that ship in an app read the installing org's settings with the setting helper, which resolves before the template renders:

JSON
{ "uri": "{{{ setting myapp.default.apiBaseUrl }}}/v1/orders/{{{steps.1.custom.orderNumberStr}}}" }

The path is <app>.<category>.<setting name>, matching the app definition's settings. You can add a default value and a type after the path: {{{ setting <app>.<category>.<name> <default> <type> }}}. Kustomer checks setting references when you save, and rejects invalid ones with Invalid app settings configuration passed.