Skip to content

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.

The script body runs inside a function, so it must return its result synchronously. Globals:

GlobalContents
$('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
sampleDataThe whole context object
console.log/info/warn/errorOutput 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:

FieldMeaning
itemIdThe request line’s id. Return this as id
pricePrice of one unit: unitPrice (× weight value if weight is set) + modifier total. Do not multiply by quantity
basePrice, modifierTotalunitPrice and the per-unit modifier sum
index, lineIndex, originalQuantityUnit position, source line position, line quantity
productId, variantId, sku, bundleIdString or null
category, brand, compareAtPrice, weightValue or null
categories, collections, tagsArrays (empty when not sent)
attributesObject of string/number/boolean values
isGift, isPreOrder, isSubscriptionBooleans
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 {
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
}
  • items must be an array on an object. A bare array, undefined, or a missing items key gives computation_no_discount.
  • id (also accepted as itemId or item_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.
  • amount is rounded to an integer. An entry with a negative, NaN or 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 same id reduce 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 in cart.redemptions. If it does, the promotion does not apply.
  • suggestion is shown as notApplied[].suggestion only when the promotion does not apply. Return { items: [], suggestion: '…' } to tell a shopper how to qualify.
OutcomenotApplied[].reason
Script threw, timed out, or has a syntax errorcomputation_error (message starts Computation error:)
Wrong shape, or no valid entriescomputation_no_discount
Valid entries, but every amount was 0 or already absorbed by earlier promotionsconditions_not_met, with a message explaining that earlier promotions already discounted the items
A redemption larger than the customer requested in cart.redemptionsconditions_not_met, with message “Redemption requires more assets than the user intended to redeem”
  1. Unit-test the logic with POST /projects/{projectId}/promotions/execute-script. It runs a script against an evalCtx that you supply. It does not normalise a cart or validate the return shape, so build evalCtx in 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.

  1. 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.

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 cent
if (points <= 0) {
return { items: [], suggestion: balance > 0 ? 'Choose how many points to use' : 'You have no points yet' }
}
const items = []
let left = points
for (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.

  • 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 same transactionId succeeds.
  • Do not set budgetCap on 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 gives computation_no_discount.
  • language: "typescript" with type annotations fails at evaluation even if the dashboard’s test run passed.