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:
- Resources are defined once per project (
POST /projects/{projectId}/resources). - Bindings list which resources a rule or promotion uses. Only bound resources are resolved and visible to its expressions.
- Conditions compare expressions over those resources.
Expressions
Section titled “Expressions”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:
| Supported | Not supported |
|---|---|
Member access a.b, a[0], a['k'], optional a?.b | Template 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 functions | new 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.
Condition groups
Section titled “Condition groups”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 } ]}| Field | Required | Notes |
|---|---|---|
combinator | yes | AND or OR. Evaluation short-circuits. |
conditions[].leftValue | yes | Non-empty string, normally an expression. |
conditions[].operator | yes | See the operator table. |
conditions[].rightValue | no | Literal (string, number, boolean, array, object) or an expression string. |
conditions[].rightType | no | Coerces rightValue; see below. |
conditions[].label | no | Display 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.
Operators
Section titled “Operators”The left value is never coerced. Only rightValue is coerced, through rightType.
| Operator (aliases) | Matches when | Important distinction |
|---|---|---|
equals (eq) | Strictly equal; objects and arrays compare by JSON | 5000 does not equal "5000" |
notEquals (neq) | Not equals | |
gt (greater_than), gte | Both sides are numbers and left > / >= right | A numeric string on either side never matches |
lt (less_than), lte | Both sides are numbers and left < / <= right | Same as above |
contains | String includes substring, or array includes the element | On arrays, an element match is strict (===) |
notContains | Inverse of contains | true when left is neither string nor array |
startsWith, endsWith | String prefix / suffix | Both sides must be strings |
regex | new RegExp(rightValue).test(left) | Pattern without slashes or flags; an invalid pattern never matches |
before, after | Both sides parse as dates and left < / > right | ISO strings or epoch milliseconds |
exists / notExists | Left is not / is null or undefined | rightValue ignored |
isEmpty / isNotEmpty | null, undefined, "", [], {} / anything else | 0 and false are not empty |
hasKey | Left is a plain object with key rightValue | |
is_true / is_false | Left is true or "true" / false or "false" | 1 and 0 do not match |
array_contains_where | Some array element has a matching field | rightValue 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" }rightType coercion
Section titled “rightType coercion”An expression in rightValue is resolved first, then coerced:
rightType | Result |
|---|---|
| omitted | Literal as sent, or the expression’s resolved value |
number | Number(value); left unchanged if not numeric |
boolean | true only for true or "true" |
string | Unchanged (no conversion) |
datetime | Converted to a string |
array, object | JSON-parsed when given as a string |
expression | Legacy; 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.
Resources
Section titled “Resources”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" }}keymust match^[a-zA-Z][a-zA-Z0-9_]*$and be unique in the project. It is the name you use in$('key').dependsOnlists keys that must resolve first. Resources with no mutual dependency resolve in parallel. A dependency cycle prevents resolution.fieldsdescribes the output shape for editors. Field keys follow the same regex. Fields do not affect resolution.isActive: false(viaPATCH) 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_errorwhen debugging.
| Type | Choose it when | Resolves to |
|---|---|---|
event | Always; rules need it as their trigger | The incoming event: { type, userId, payload } |
internal | You need Storelayer data: wallet, customer, history, or any read tool | The tool’s output (shapes below) |
custom_table | You need data you maintain in custom tables: tiers, eligibility lists | One row, or an array of rows |
http | The decision needs your own service’s data at decision time | Parsed JSON or text |
database | The data lives in your Postgres, reachable through an integration | One row object, or an array of rows |
payload | You need fixed shared constants | config.data |
code | No other type can compute the value | The 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.
internal
Section titled “internal”Calls a Storelayer read tool. You can use a legacy entity shortcut or name a tool directly:
| Config | Tool called | $('key') resolves to |
|---|---|---|
{ "entity": "wallet" } | wallet.get_balance | See 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_user | The customer plus _lookupMethod: "direct", or { _error, _lookupMethod: "none" } |
{ "toolName": "<domain.tool>", "params": { ... } } | Any registered tool | The 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 }}.
custom_table
Section titled “custom_table”{ "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}methodisGET,POST,PUT,PATCHorDELETE.urlcan contain{{ }}placeholders anywhere in the string.- Each
headersvalue and eachbodyTemplatevalue (a flat object of strings, ignored forGET) is either a literal or a whole{{ }}expression. authentication.typeisnone,bearer(token),basic(username,password) orapi-key(key,value, sent as a header).responseMappingis a dot path into the JSON response.timeoutis 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.
database
Section titled “database”{ "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.
payload
Section titled “payload”{ "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
Dateobjects. scriptis at most 100,000 characters.
Binding resources
Section titled “Binding resources”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.
Test before saving
Section titled “Test before saving”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.
Pitfalls
Section titled “Pitfalls”$('event').amountinstead of$('event').payload.amount.- Numeric strings:
"5000"failsgt,gte,ltandlte. Send numbers, or wrap withNumber(). rightType: "number"on an expressionrightValuenever matches live. OmitrightType.- A new customer has no wallet entry. Use
?? 0. - Money values are integer minor units (cents):
5000is 50.00. - Nested condition groups are rejected. Use one
is_trueexpression. useranduser_lookupresources without an explicit user expression resolve to_error.- A resource that exists but isn’t bound resolves to
undefined.
See also
Section titled “See also”- Custom tables
- Creating promotions
- Loyalty program
- Debugging
- Rules, Resources v2 and Internal Tools in the API reference