Debugging Rules
Debug from the first point where the data stops matching what you expect: request, then workflow, then rule, then action, then wallet. The workflow record (GET /projects/{projectId}/workflows/{eventId}) answers most questions. Do not re-ingest an event to “see if it works now”. That creates a second workflow and can award twice.
Collect first
Section titled “Collect first”Gather the project, the event id (evt_…, which is also the workflow id), userId, the rule id, the exact request body, the expected result and the observed result.
Playbook
Section titled “Playbook”| Symptom | Check | Typical cause and fix |
|---|---|---|
Ingest returns 400 | The error array: each issue has path and message | payload missing, type empty, or userId not a string (123 → "123"). |
Ingest 401 (code: 1002) | Header | Send the key in X-API-Key or Authorization: Bearer <key>. |
Ingest 201 but a field “isn’t there” | triggerData in the workflow | Unknown top-level fields are silently dropped. Move amount, orderId and similar fields into payload. |
Workflow stays 404 | Poll longer, then GET /workflows?eventId=evt_… | Processing is queued. Missing after a long wait: report it with the event id instead of re-sending. |
Rule absent from rules[] | GET /projects/{projectId}/rules/{ruleId}: isActive, validFrom/validTo, event resource key vs event type | Inactive, out of window, or bound to another type (purchase vs order_completed, case-sensitive). |
Rule in rules[] with matched: false | Rerun its conditions in POST /rules/test with {"event": triggerData} plus any resource values from resolvedContext | Look at results[].resolvedLeft. undefined means a wrong path ($('event').amount → $('event').payload.amount) or an undeclared resource. |
Condition reads undefined for a resource | Rule resources[] and resolvedContext | The resource is not declared on the rule (it is silently undefined) or not registered on the project. Add it to the rule’s resources. |
| Resource failed | resources[] entry with status: "failed" and error | Fix the data source (URL, auth, table, script). Dependent conditions evaluated false. |
| Rule fires on unrelated events | Rule resources[] | The rule has no event-type binding (generic event key and no $('event').type condition), so it matches every type. |
Action failed: Reward action requires userId | triggerData.userId, resolvedUserId | The event was sent without userId, or the userId expression resolved to nothing. |
Action failed: … amount must be a positive number | actions[].payload.amount | The amount expression came out negative. |
Action completed with result.skipped: true | actions[].payload.amount | The expression came out as 0 (for example Math.floor(amount / 100) on 49 cents). |
Action failed: HTTP 4xx/5xx or Integration call failed | actions[].payload (resolved body) and result.body | Receiver rejected it, timeout, or wrong URL or auth. See HTTP integrations. |
Workflow failed | Top-level error. actions is empty | An action threw, for example Insufficient balance for asset 'points' on a redemption. Earlier actions may have applied, so check the wallet. |
| Reward 100× too big or never matches | Payload amount vs thresholds | Units mix-up. Send integer cents (7450), compare in cents (5000), divide by 100 for points per dollar. |
| Reward applied twice | Two workflows for the same payload.orderId | The event was ingested twice. Ingest has no idempotency key, so deduplicate at the source. |
Literal {{ … }} text in a description or webhook body | Config value | Only a value that is entirely one {{ … }} expression is evaluated. Use "{{ 'Order ' + $('event').payload.orderId }}". |
Reproduce a condition
Section titled “Reproduce a condition”POST /projects/{projectId}/rules/test{ "conditions": { "combinator": "AND", "conditions": [ { "leftValue": "{{ $('event').payload.amount }}", "operator": "gte", "rightValue": 5000, "rightType": "number" }, { "leftValue": "{{ $('wallet').balances.points }}", "operator": "lt", "rightValue": 10000, "rightType": "number" } ]}, "context": { "event": { "type": "purchase", "userId": "cust_8421", "payload": { "amount": 7450 } }, "wallet": { "balances": { "points": 2300 } } }}/rules/test resolves nothing on its own. You supply every resource value. That is why it is the right tool for “is the expression right?”, while the workflow’s resolvedContext answers “what data did the live rule see?”.
POST /projects/{projectId}/rules/evaluate runs all active rules against a context but does not apply the live event-type binding. A rule bound only by its event resource key can match there and still not run live. Treat /rules/test plus the workflow record as the source of truth.
Avoid side effects while debugging
Section titled “Avoid side effects while debugging”POST /projects/{projectId}/rules/execute-actionsand/rules/executerun real actions. They write to wallets and call integrations. Use a test user or test project.- To check a balance or ledger, read
GET /projects/{projectId}/users/{userId}/walletand.../wallet/transactions. Do not call earn or spend to “probe”.
Report
Section titled “Report”Include the redacted request, the evt_ id, the rule id, the relevant workflow excerpt (rules, actions, resources, error), and the expected and observed wallet state. Never include API keys or integration secrets.
For promotions (cart discounts), debug with POST /projects/{projectId}/promotions/evaluate instead. See Creating promotions and Discount scripts.
See also
Section titled “See also”Workflows · Conditions and resources · Loyalty events · Sending business events