Discount Scripts
A custom_script application method runs your JavaScript in a sandbox for each evaluation. It reads the same context as conditions and returns per-item discount amounts in cents. Use it only when standard or buyget cannot express the offer, for example tiered math or paying with wallet points. Conditions still run first; the script runs only if they pass.
"applicationMethod": { "methodType": "custom_script", "language": "javascript", "script": "…" }Use "language": "javascript". Scripts are executed as stored, and TypeScript is not compiled during evaluation, so type annotations cause a syntax error (computation_error). The dashboard editor compiles TypeScript for its own test runs only.
What the script sees
Section titled “What the script sees”The script body runs inside a function, so it must return its result synchronously. Globals:
| Global | Contents |
|---|---|
$('cart') | The cart the engine derived: subtotal, shippingTotal, taxTotal, total, itemCount, categories, currencyCode, …, plus items and redemptions |
$('customer') / $('user'), $('context'), $('time'), $('couponCodes') | As in conditions |
$('<resourceKey>') | Each resource listed in the promotion’s resources |
$.cart, $.customer, … | The same values as properties |
sampleData | The whole context object |
console.log/info/warn/error | Output is returned only by execute-script; it is discarded during evaluation |
Limits: 5 seconds of execution and 10 MB of memory. Only the globals above are available; there is no network access.
$('cart').items contains one entry per unit, not per line. A line with quantity: 3 appears three times with the same itemId. Each entry has:
| Field | Meaning |
|---|---|
itemId | The request line’s id. Return this as id |
price | Price of one unit: unitPrice (× weight value if weight is set) + modifier total. Do not multiply by quantity |
basePrice, modifierTotal | unitPrice and the per-unit modifier sum |
index, lineIndex, originalQuantity | Unit position, source line position, line quantity |
productId, variantId, sku, bundleId | String or null |
category, brand, compareAtPrice, weight | Value or null |
categories, collections, tags | Arrays (empty when not sent) |
attributes | Object of string/number/boolean values |
isGift, isPreOrder, isSubscription | Booleans |
modifiers | [{ id, name, group, unitPrice, quantity }] |
The request’s unitPrice, quantity and item metadata are not on these entries. item.unitPrice * item.quantity evaluates to NaN, and the engine drops NaN amounts.
Return shape
Section titled “Return shape”return { items: [ { id: 'line-1', amount: 300 }, // cents off this item id { id: 'line-2', amount: 150, redemption: { type: 'points', amount: 150 } } ], suggestion: 'Add 2000 more to unlock 15% off' // optional string}itemsmust be an array on an object. A bare array,undefined, or a missingitemskey givescomputation_no_discount.id(also accepted asitemIdoritem_id) should be a cart line id. An amount sent to an id that is not in the cart (for example"__order__") is spread across all units in proportion to price, and a warning is added.amountis rounded to an integer. An entry with a negative,NaNor non-numeric amount is skipped. Each amount is capped at what is left of that item’s price, after promotions with higher priority have applied. Repeated entries for the sameidreduce the same units.redemption: { type, amount }on an entry asks to spend wallet assets on a recorded checkout. Attach it to one entry. The amount cannot exceed what the customer requested for that type incart.redemptions. If it does, the promotion does not apply.suggestionis shown asnotApplied[].suggestiononly when the promotion does not apply. Return{ items: [], suggestion: '…' }to tell a shopper how to qualify.
| Outcome | notApplied[].reason |
|---|---|
| Script threw, timed out, or has a syntax error | computation_error (message starts Computation error:) |
| Wrong shape, or no valid entries | computation_no_discount |
| Valid entries, but every amount was 0 or already absorbed by earlier promotions | conditions_not_met, with a message explaining that earlier promotions already discounted the items |
A redemption larger than the customer requested in cart.redemptions | conditions_not_met, with message “Redemption requires more assets than the user intended to redeem” |
Test it
Section titled “Test it”- Unit-test the logic with
POST /projects/{projectId}/promotions/execute-script. It runs a script against anevalCtxthat you supply. It does not normalise a cart or validate the return shape, so buildevalCtxin the normalised form:
{ "script": "const items = $('cart').items; console.log(items.length); return { items: items.map(i => ({ id: i.itemId, amount: Math.round(i.price * 0.1) })) }", "evalCtx": { "cart": { "subtotal": 4000, "redemptions": [], "items": [ { "itemId": "line-1", "price": 2000 }, { "itemId": "line-1", "price": 2000 } ] } }}The response is { "success": true, "data": { "success": true, "result": { "items": [...] }, "logs": ["2"] } }, or data.success: false with error.
- Test the real behavior with an evaluate dry run (
dryRun: true,includeInactiveIds: ["promo_…"]) on a real cart. Only this path applies normalisation, resources, capping and the reason codes.
Example: pay with wallet points
Section titled “Example: pay with wallet points”Goal: 1 point = 1 cent off. The shopper chooses how many points to use, and the discount can never exceed their balance.
1. Define a wallet resource on the project. Resources are not created automatically.
POST /projects/{projectId}/resources
{ "key": "wallet", "name": "Customer wallet", "type": "internal", "config": { "toolName": "wallet.get_balance" } }During evaluation the resource loads the wallet of cart.userId. $('wallet').balances.points is that customer’s points balance ($('wallet').points.balance also works).
2. Create the promotion, list the resource, and give it the highest priority so it runs before other promotions reduce item prices:
{ "name": "Pay with points", "status": "active", "priority": 100, "resources": { "wallet": {} }, "conditions": { "combinator": "AND", "conditions": [ { "leftValue": "{{ $('cart').redemptions }}", "operator": "isNotEmpty" } ] }, "applicationMethod": { "methodType": "custom_script", "language": "javascript", "script": "<script below>" }}const cart = $('cart')const wallet = $('wallet') || {}const requested = (cart.redemptions || []) .filter((r) => r.type === 'points') .reduce((sum, r) => sum + r.amount, 0)const balance = (wallet.balances && wallet.balances.points) || 0
const points = Math.min(requested, balance, cart.subtotal) // 1 point = 1 centif (points <= 0) { return { items: [], suggestion: balance > 0 ? 'Choose how many points to use' : 'You have no points yet' }}
const items = []let left = pointsfor (const unit of cart.items) { if (left <= 0) break const amount = Math.min(unit.price, left) items.push({ id: unit.itemId, amount }) left -= amount}items[0].redemption = { type: 'points', amount: points }return { items }3. Preview, then record. Send cart.userId and cart.redemptions: [{ "type": "points", "amount": 1500 }]. A dry run returns redemptions: [{ type: "points", amount: 1500, id: "<promotionId>" }] and spends nothing. The same request with dryRun: false and a transactionId spends the points from that user’s wallet, using transactionId as the reference. A retry with the same transactionId does not spend again.
Pitfalls
Section titled “Pitfalls”- Cap redemptions by the balance in the script. If a spend fails at checkout because the balance is too low, the checkout stays
pending. It cannot be cancelled until a retry with the sametransactionIdsucceeds. - Do not set
budgetCapon a redemption promotion, and keep its priority high. Budget clamping and earlier promotions can reduce the discount, but the requested points are still spent. - Cancelling a usage does not refund points. Credit them back with the wallet API.
- A script that never returns (only logs) yields
undefined, which givescomputation_no_discount. language: "typescript"with type annotations fails at evaluation even if the dashboard’s test run passed.
See also
Section titled “See also”- Promotion concepts
- Creating promotions
- Checkout integration
- Wallets
- Promotions in the API reference