Skip to content

Conditions and Resources

A rule or promotion decides by comparing values. Each value comes from a resource: a named piece of data that Storelayer resolves for one execution, such as the incoming event, the customer’s wallet, a custom table row, or an HTTP response. Expressions read resources with $('key'), and a condition compares a resolved left value against a right value with an operator.

The model has three parts:

  1. Resources are defined once per project (POST /projects/{projectId}/resources).
  2. Bindings list which resources a rule or promotion uses. Only bound resources are resolved and visible to its expressions.
  3. Conditions compare expressions over those resources.

An expression is a string wrapped in {{ }}. The whole string must be the expression: "{{ $('event').payload.amount }}" is evaluated, but "Order {{ $('event').payload.orderId }}" in a condition is compared as a literal string. Strings without {{ }} are literals.

{{ $('event').type }} "purchase"
{{ $('event').payload.amount }} 5000
{{ $('event').payload.items[0].sku }} "SKU-1"
{{ $('wallet').points.balance ?? 0 }} 120
{{ Math.floor($('event').payload.amount / 100) }} 50
{{ $('event').payload.items.reduce((s, i) => s + i.quantity, 0) }}
{{ $('event').payload.items.filter(i => i.category === 'coffee').length }}
{{ $('event').payload.tags.includes('vip') }}
{{ $('event').payload.amount >= 10000 ? 'big' : 'small' }}
{{ 'user-' + $('event').userId }}

The evaluator is a safe JavaScript subset, not full JavaScript:

SupportedNot supported
Member access a.b, a[0], a['k'], optional a?.bTemplate literals (`x${y}`); use +
Arithmetic + - * / % **, comparison === !== == != > < >= <=Object literals ({ a: 1 })
Logic && || !, nullish ??, ternary ? :Assignment and statements
Arrow functions and methods (filter, map, reduce, some, includes, toLowerCase, …)Plain identifiers for context: {{ event.userId }} is undefined
Globals: Math, JSON, String, Number, Boolean, Array, Object, Date, parseInt, parseFloat, isNaN, isFinite, encodeURIComponent and related URI functionsnew with anything other than Date, Array, Object
$now (ISO timestamp), $today (YYYY-MM-DD)

Read context only through $('key'). Resource keys are not variables in scope. Accessing a missing path returns undefined instead of throwing, and an expression that fails to parse or throws also resolves to undefined. A typo therefore looks like missing data, not an error.

Rules and promotions use the same flat shape: one combinator over a list of conditions.

{
"combinator": "AND",
"conditions": [
{ "leftValue": "{{ $('event').type }}", "operator": "equals", "rightValue": "purchase" },
{ "leftValue": "{{ $('event').payload.amount }}", "operator": "gte", "rightValue": 5000, "rightType": "number" },
{ "label": "Has 100+ points", "leftValue": "{{ $('wallet').points.balance ?? 0 }}", "operator": "gte", "rightValue": 100 }
]
}
FieldRequiredNotes
combinatoryesAND or OR. Evaluation short-circuits.
conditions[].leftValueyesNon-empty string, normally an expression.
conditions[].operatoryesSee the operator table.
conditions[].rightValuenoLiteral (string, number, boolean, array, object) or an expression string.
conditions[].rightTypenoCoerces rightValue; see below.
conditions[].labelnoDisplay text (rules only).

Groups do not nest. An item inside conditions must be a condition, so a nested { combinator, conditions } is rejected for both rules and promotions. To mix AND and OR, put the logic in one expression and test it with is_true:

{
"leftValue": "{{ $('event').payload.channel === 'app' || $('event').payload.amount >= 10000 }}",
"operator": "is_true"
}

A rule needs at least one condition. For rules, a condition {{ $('event').type }} equals "<type>" also tells Storelayer which events the rule listens to; see loyalty events.

The left value is never coerced. Only rightValue is coerced, through rightType.

Operator (aliases)Matches whenImportant distinction
equals (eq)Strictly equal; objects and arrays compare by JSON5000 does not equal "5000"
notEquals (neq)Not equals
gt (greater_than), gteBoth sides are numbers and left > / >= rightA numeric string on either side never matches
lt (less_than), lteBoth sides are numbers and left < / <= rightSame as above
containsString includes substring, or array includes the elementOn arrays, an element match is strict (===)
notContainsInverse of containstrue when left is neither string nor array
startsWith, endsWithString prefix / suffixBoth sides must be strings
regexnew RegExp(rightValue).test(left)Pattern without slashes or flags; an invalid pattern never matches
before, afterBoth sides parse as dates and left < / > rightISO strings or epoch milliseconds
exists / notExistsLeft is not / is null or undefinedrightValue ignored
isEmpty / isNotEmptynull, undefined, "", [], {} / anything else0 and false are not empty
hasKeyLeft is a plain object with key rightValue
is_true / is_falseLeft is true or "true" / false or "false"1 and 0 do not match
array_contains_whereSome array element has a matching fieldrightValue is "field=value" (string compare) or { "field": "sku", "value": "A" } (strict compare)

Example: the event contains at least one item from the coffee category.

{ "leftValue": "{{ $('event').payload.items }}", "operator": "array_contains_where", "rightValue": "category=coffee" }

An expression in rightValue is resolved first, then coerced:

rightTypeResult
omittedLiteral as sent, or the expression’s resolved value
numberNumber(value); left unchanged if not numeric
booleantrue only for true or "true"
stringUnchanged (no conversion)
datetimeConverted to a string
array, objectJSON-parsed when given as a string
expressionLegacy; same as omitted

Send numbers as JSON numbers, or set "rightType": "number". {"operator": "gte", "rightValue": "1000"} with no rightType never matches a numeric left value. If the left value is a numeric string, convert it in the expression: {{ Number($('event').payload.amount) }}.

When rightValue is an expression, omit rightType. Live rule processing reads "rightType": "number" with an expression rightValue as NaN, so the condition never matches, while the /rules/test endpoint resolves it correctly. Write {"rightValue": "{{ $('tier').minSpend }}"} with no rightType, and make the expression itself return a number.

Every resource has a key, a name, a type, a type-specific config, and optional dependsOn and fields.

POST /projects/{projectId}/resources
{
"key": "wallet",
"name": "Customer wallet",
"type": "internal",
"config": { "entity": "wallet" }
}
  • key must match ^[a-zA-Z][a-zA-Z0-9_]*$ and be unique in the project. It is the name you use in $('key').
  • dependsOn lists keys that must resolve first. Resources with no mutual dependency resolve in parallel. A dependency cycle prevents resolution.
  • fields describes the output shape for editors. Field keys follow the same regex. Fields do not affect resolution.
  • isActive: false (via PATCH) stops a resource from resolving.
  • A failed resource does not fail the execution. Its value becomes { "_error": "..." }, and conditions that read it usually don’t match. Check for _error when debugging.
TypeChoose it whenResolves to
eventAlways; rules need it as their triggerThe incoming event: { type, userId, payload }
internalYou need Storelayer data: wallet, customer, history, or any read toolThe tool’s output (shapes below)
custom_tableYou need data you maintain in custom tables: tiers, eligibility listsOne row, or an array of rows
httpThe decision needs your own service’s data at decision timeParsed JSON or text
databaseThe data lives in your Postgres, reachable through an integrationOne row object, or an array of rows
payloadYou need fixed shared constantsconfig.data
codeNo other type can compute the valueThe script’s return value

config is {}. In rules, the event is always available as $('event'): { "type": "purchase", "userId": "user_123", "payload": { ... } }. Event fields sit under payload, so the order amount is $('event').payload.amount, not $('event').amount. userId is null when the event has none. Give the event resource the key event; it does not rename the event data.

Calls a Storelayer read tool. You can use a legacy entity shortcut or name a tool directly:

ConfigTool called$('key') resolves to
{ "entity": "wallet" }wallet.get_balanceSee wallet shape below
{ "entity": "history" }wallet.list_transactions (limit 100){ items: [...], total }, newest first; each item has id, type (earn, spend, expire, adjust), assetType, amount, balanceAfter, referenceId, description, createdAt
{ "entity": "user", "userIdExpression": "{{ $('event').userId }}" }external_users.get_user{ id, firstName, lastName, email, phone, birthday, gender, metadata, createdAt, updatedAt }; metadata is a parsed object
{ "entity": "user_lookup", "lookupExpression": "{{ $('event').userId }}" }external_users.lookup_userThe customer plus _lookupMethod: "direct", or { _error, _lookupMethod: "none" }
{ "toolName": "<domain.tool>", "params": { ... } }Any registered toolThe tool’s output

The user ID defaults to {{ $('event').userId }} for wallet and history. For user and user_lookup, set userIdExpression or lookupExpression explicitly. Their built-in defaults use a form the evaluator does not resolve, so without it the resource resolves to an _error.

With toolName, top-level string values in params that are {{ }} expressions are resolved; nested values are passed through unchanged. projectId is filled in automatically. Discover tool names and parameter schemas with GET /projects/{projectId}/internal-tools?category=read. Use read tools only.

{ "toolName": "wallet.list_transactions", "params": { "userId": "{{ $('event').userId }}", "type": "earn", "limit": 20 } }

Wallet shape. Every asset type appears three ways, so all of these work:

{
"points": { "assetType": "points", "balance": 120, "lifetimeEarned": 450, "lifetimeSpent": 330, "lifetimeExpired": 0, "nextExpiration": null },
"balances": { "points": 120 },
"assets": { "points": { "assetType": "points", "balance": 120, "lifetimeEarned": 450, "lifetimeSpent": 330, "lifetimeExpired": 0, "nextExpiration": null } }
}

$('wallet').points.balance, $('wallet').balances.points and $('wallet').assets.points.balance are equivalent. A customer who never earned an asset has no entry for it, so write {{ $('wallet').points.balance ?? 0 }}.

{ "tableName": "tiers", "lookupField": "name", "lookupExpression": "{{ $('customer').metadata.tier }}" }

With lookupField and lookupExpression, the resource returns the first row where the column equals the resolved value, or null if no row matches. If the lookup value resolves to null or undefined, it falls back to defaultFilter (equality on every listed column, { "active": 1 }), and otherwise to all rows. Both of those return an array of at most 50 rows. See custom tables for a full example.

{
"url": "https://api.example.com/customers/{{ $('event').userId }}/segment",
"method": "GET",
"headers": { "X-Tenant": "acme" },
"authentication": { "type": "bearer", "token": "..." },
"responseMapping": "data.segment",
"timeout": 5000
}
  • method is GET, POST, PUT, PATCH or DELETE. url can contain {{ }} placeholders anywhere in the string.
  • Each headers value and each bodyTemplate value (a flat object of strings, ignored for GET) is either a literal or a whole {{ }} expression.
  • authentication.type is none, bearer (token), basic (username, password) or api-key (key, value, sent as a header).
  • responseMapping is a dot path into the JSON response. timeout is in milliseconds, default 30000.
  • A non-2xx response is not an error: its body becomes the value. Network failures and timeouts give _error.

HTTP resources run on the decision path for every execution that uses them. Keep them fast, or copy slow-changing data into a custom table.

{ "integrationId": "int_abc", "query": "select tier from customers where id = $1", "params": ["{{ $('event').userId }}"] }

The integration must be an active postgres integration. params fill $1, $2, … A single-row result resolves to that row object; any other result is an array.

{ "data": { "pointsPerDollar": 10, "launchDate": "2026-11-01" } }

Static constants, read as $('constants').pointsPerDollar. If the trigger context already has the same key, the trigger value wins.

{ "script": "const items = $('event').payload.items || [];\nreturn items.filter(i => i.category === 'coffee').reduce((s, i) => s + i.quantity, 0);" }

The script runs in an isolated JavaScript sandbox, and its return value becomes the resource value. Read other resources with $('key') or $.key, and list them in dependsOn so they resolve first. Limits:

  • Plain JavaScript, synchronous. No network, no fetch, no imports.
  • 5 seconds of execution and 10 MB of memory. A script that exceeds either resolves to _error.
  • Input and output pass as JSON. Return plain data, not functions or Date objects.
  • script is at most 100,000 characters.

Resources are not auto-created by the REST API, and an unbound resource is invisible. Create the resource first, then bind it.

Rules. The rule’s resources array lists bindings as { id, key, type, value }. id is the resource’s ID from GET /projects/{projectId}/resources, type must equal the registered type, and value can be {}. POST /projects/{projectId}/rules rejects a rule whose bindings include no event resource, a key that is not registered, or a type that doesn’t match. During processing, a rule’s expressions see the event plus only its bound resources.

"resources": [
{ "id": "res_evt123", "key": "event", "type": "event", "value": {} },
{ "id": "res_wal456", "key": "wallet", "type": "internal", "value": {} }
]

Promotions. The promotion’s resources object is keyed by resource key, for example "resources": { "wallet": {}, "customer": {}, "tier": {} }. Only resources bound by candidate promotions are resolved. List every dependency too: a promotion resolves only the keys it names, so a dependency left out never resolves. In promotion context, cart, customer, user, context, time and couponCodes are built-in namespaces that take precedence over resources with the same key, and $('event').userId is the cart’s user. Bind wallet and similar resources under other keys. See creating promotions.

The agent tools (project_add_rule, project_update_rule) are more forgiving than the REST API. They create a missing event resource, create wallet, user and history resources when conditions reference them, and bind every registered resource that conditions and actions reference. Over REST, do these steps yourself.

Resolve real data first, then test the conditions against it.

1. Resolve resources with a sample trigger. The response’s data is the full context: your trigger plus every resolved resource, including _error values. resourceKeys limits resolution to those keys and their dependencies; omit it to resolve all of them.

POST /projects/{projectId}/resources/resolve
{
"triggerContext": {
"event": { "type": "purchase", "userId": "user_123", "payload": { "amount": 5000, "items": [] } }
},
"resourceKeys": ["wallet", "tier"]
}

2. Test the condition group against that context. Nothing is saved and no resources are fetched: the endpoint evaluates against exactly the context you send.

POST /projects/{projectId}/rules/test
{
"conditions": {
"combinator": "AND",
"conditions": [
{ "leftValue": "{{ $('event').payload.amount }}", "operator": "gte", "rightValue": 5000 },
{ "leftValue": "{{ $('wallet').points.balance ?? 0 }}", "operator": "gte", "rightValue": 100 }
]
},
"context": { "event": { "type": "purchase", "userId": "user_123", "payload": { "amount": 5000 } }, "wallet": { "points": { "balance": 120 } } }
}

The response has matched and one result per evaluated condition with resolvedLeft and resolvedRight. Evaluation stops at the first deciding condition. If resolvedLeft is missing from a result, it resolved to undefined: the path is wrong or the resource is not in the context.

POST /projects/{projectId}/rules/evaluate with { "context": { "event": { ... } } } resolves resources and reports which saved active rules match, without running actions.

A passing test proves the comparison for that sample. It does not prove the action will succeed or that the live event has the same payload shape.

  • $('event').amount instead of $('event').payload.amount.
  • Numeric strings: "5000" fails gt, gte, lt and lte. Send numbers, or wrap with Number().
  • rightType: "number" on an expression rightValue never matches live. Omit rightType.
  • A new customer has no wallet entry. Use ?? 0.
  • Money values are integer minor units (cents): 5000 is 50.00.
  • Nested condition groups are rejected. Use one is_true expression.
  • user and user_lookup resources without an explicit user expression resolve to _error.
  • A resource that exists but isn’t bound resolves to undefined.