Skip to content

Creating Promotions

The workflow is: create the promotion as a draft, preview it with dry-run evaluations until the numbers are right, activate it, then record real checkouts with dryRun: false. Every amount below is in cents.

POST /projects/{projectId}/promotions

{
"name": "10% off orders over $50",
"conditions": {
"combinator": "AND",
"conditions": [
{ "leftValue": "{{ $('cart').subtotal }}", "operator": "gte", "rightValue": 5000 }
]
},
"applicationMethod": {
"methodType": "standard",
"discountType": "percentage",
"value": 10,
"targetType": "order",
"allocation": "across"
},
"priority": 0,
"stackingMode": "stackable",
"maxUsesPerUser": 1,
"validTo": "2026-12-31T23:59:59Z"
}

Only name is required. status defaults to draft. Store the returned data.id (promo_…).

  • $('cart').subtotal is calculated by the engine from item prices, so a client cannot inflate it.
  • across rounds once for the whole order. each would round 10% separately on every unit.
  • Set validFrom only after you finish previewing, or set it in the past. A future validFrom pre-filters the promotion with reason not_yet_active, even on a dry run.

POST /projects/{projectId}/promotions/evaluate

{
"dryRun": true,
"includeInactiveIds": ["promo_abc123"],
"cart": {
"currencyCode": "USD",
"userId": "user_42",
"items": [
{ "id": "line-1", "productId": "sku-jacket", "quantity": 1, "unitPrice": 3000 },
{ "id": "line-2", "productId": "sku-socks", "quantity": 2, "unitPrice": 1000 }
]
}
}
  • dryRun defaults to true. A dry run never consumes uses, budget, coupons or wallet points.
  • includeInactiveIds lets the listed non-active promotions through the status check while every other promotion must still be active. This previews the draft against the live campaigns it will compete with.
  • includeInactive: true admits every non-active promotion.
  • Both flags work on dry runs only. includeInactiveIds is rejected with dryRun: false, and includeInactive is ignored. Date, cap, budget and coupon checks still apply.

The response (data), trimmed:

{
"applied": [{
"id": "promo_abc123", "name": "10% off orders over $50", "totalDiscount": 500,
"discounts": [
{ "amount": 300, "affectedItemIds": ["line-1"], "description": "10% off orders over $50: -10% across 3 items" },
{ "amount": 100, "affectedItemIds": ["line-2"] },
{ "amount": 100, "affectedItemIds": ["line-2"] }
]
}],
"notApplied": [],
"items": [
{ "lineIndex": 0, "itemId": "line-1", "quantity": 1, "unitPrice": 3000, "originalLineTotal": 3000,
"discountTotal": 300, "discountPercentage": 10, "adjustedUnitPrice": 2700, "lineTotal": 2700,
"adjustments": [{ "promotionId": "promo_abc123", "promotionName": "10% off orders over $50", "amount": 300 }] },
{ "lineIndex": 1, "itemId": "line-2", "quantity": 2, "unitPrice": 1000, "originalLineTotal": 2000,
"discountTotal": 200, "discountPercentage": 10, "adjustedUnitPrice": 900, "lineTotal": 1800,
"adjustments": [{ "promotionId": "promo_abc123", "amount": 100, "…": "…" }, { "promotionId": "promo_abc123", "amount": 100, "…": "…" }] }
],
"summary": { "itemsOriginalTotal": 5000, "itemsDiscountTotal": 500, "shippingOriginalTotal": 0,
"shippingDiscountTotal": 0, "discountTotal": 500, "total": 4500, "savingsPercentage": 10,
"currencyCode": "USD", "appliedCount": 1 },
"warnings": ["Promotion \"10% off orders over $50\" is draft and was evaluated for preview only — it will not apply to real carts until it is activated."],
"trace": [{ "promotionId": "promo_abc123", "order": 0, "provisionalDiscount": 500, "finalDiscount": 500, "outcome": "applied" }],
"dryRun": true
}

Use items[] to price lines in your cart. It has one entry per discounted request line, matched by lineIndex, and lines without a discount are left out. Sum a line’s adjustments (or read discountTotal) rather than counting entries: a promotion can add several adjustments to one line, as with the quantity-2 line above. applied[].discounts lists the same money per unit.

Drop line-2 to one unit (subtotal 4000) and the promotion moves to notApplied:

{ "id": "promo_abc123", "reason": "conditions_not_met", "message": "…",
"suggestion": "…",
"failedConditions": [{ "field": "{{ $('cart').subtotal }}", "operator": "gte", "expected": 5000, "actual": 4000, "message": "…" }] }
reasonMeaningFix
conditions_not_metA condition failed; see failedConditionsCheck the expression path and units
computation_no_discountConditions passed but no unit qualified, or a script returned no valid itemsCheck targetRules / script output
computation_errorThe script threw or timed outTest it with execute-script
invalid_conditionsThe stored condition group is malformedRe-save conditions
excluded_by_stackingAnother promotion won stackingCheck priority/stackingMode; trace[].excludedBy names the winner
coupon_requiredrequiresCoupon and no valid code for itRead warnings for the coupon error
inactive, expired, depletedStatus (or validTo passed). status is included when the cause is the statusActivate, or extend the dates
not_yet_activeBefore validFrom; startsAt is given
capacity_exhausted, budget_exhaustedmaxTotalUses, maxUsesPerUser or budgetCap reached

Test the boundaries: 4999 gives no discount, 5000 gives 500. Then preview a cart with your existing live promotions to check stacking.

PATCH /projects/{projectId}/promotions/{promotionId} with { "status": "active" }.

At order placement, repeat the same evaluation with dryRun: false:

{
"dryRun": false,
"transactionId": "checkout_7f3a9",
"orderId": "order_1001",
"couponCodes": [],
"cart": { "currencyCode": "USD", "userId": "user_42", "items": [ /* same items */ ] }
}

transactionId and cart.userId are required. The response is the same shape, plus usages: [{ id, transactionId }]. Recording increments totalUses, budgetUsed, the customer’s per-user count and coupon uses, and spends any wallet redemptions.

Retrying with the same transactionId and an identical body is safe. The engine returns the stored result with duplicate: true and does not charge anything twice. A different body under the same transactionId returns 409 TRANSACTION_CONFLICT. If a promotion or coupon stopped being eligible between evaluation and recording (for example, another order used the last coupon), the call returns 409 CHECKOUT_STALE. Re-preview the cart and show the customer the new price. See checkout integration.

For a refunded or abandoned order: POST /projects/{projectId}/promotions/usage/{usageId}/cancel. This reverses the uses, budget and coupon counts, and returns depleted promotions and exhausted coupons to active when capacity is freed. It does not refund wallet points; credit them back through the wallet API. A cancelled transactionId cannot be reused: a new checkout needs a new id. Find usages with GET /projects/{projectId}/promotions/usage?transactionId=… (or orderId, userId).

ActionEndpointBody
Create onePOST /projects/{projectId}/promotions/{promotionId}/coupons{ "code": "WELCOME10", "maxUses": 500, "maxUsesPerUser": 1, "validTo": "2026-12-31T23:59:59Z" }
Generate manyPOST …/{promotionId}/coupons/bulk{ "count": 200, "prefix": "VIP", "maxUses": 1 } gives codes like VIP-7K2M9QXA
Look upGET /projects/{projectId}/promotions/coupons/lookup/{code}404 COUPON_NOT_FOUND if missing
Update / deletePATCH / DELETE /projects/{projectId}/promotions/coupons/{couponId}status: active, inactive, expired, exhausted
  • Codes are stored uppercase and matched case-insensitively.
  • A code is unique across the project; a duplicate returns 409.
  • Without code, the single-create endpoint generates PROMO-XXXXXXXX.
  • count must be between 1 and 1000.
  • A coupon whose maxUses is reached is set to exhausted.
  • Pass codes as couponCodes: ["WELCOME10"] on evaluate. Unknown, expired or used-up codes produce entries in warnings (for example Coupon code "X" not found), not in notApplied.

Each recipe shows only the fields that differ from the example above.

Free shipping over $75: conditions on {{ $('cart').subtotal }} gte 7500, plus:

{ "methodType": "standard", "discountType": "percentage", "value": 100, "targetType": "shipping", "allocation": "across",
"targetRules": { "combinator": "AND", "conditions": [
{ "leftValue": "{{ $('shipping').type }}", "operator": "equals", "rightValue": "standard" } ] } }

The cart must include shippingMethods. Without them the reason is computation_no_discount (“No shipping methods in cart”).

Buy one shoe, get one free (cheapest free):

{ "methodType": "buyget",
"buyRules": { "combinator": "AND", "conditions": [{ "leftValue": "{{ $('item').category }}", "operator": "equals", "rightValue": "shoes" }] },
"buyQuantity": 1,
"targetRules": { "combinator": "AND", "conditions": [{ "leftValue": "{{ $('item').category }}", "operator": "equals", "rightValue": "shoes" }] },
"targetQuantity": 1,
"discountType": "percentage", "value": 100 }

Two shoes give one free and four give two free. The paying unit is never the free one.

20% off one category: { "methodType": "standard", "discountType": "percentage", "value": 20, "targetType": "items", "allocation": "each", "targetRules": { "combinator": "AND", "conditions": [{ "leftValue": "{{ $('item').category }}", "operator": "equals", "rightValue": "Outerwear" }] } }. The category match ignores case.

Fixed amount: each or across, for a cart with two eligible units at 3000:

Method fields ("discountType": "fixed", "value": 500, plus)Result
"targetType": "items", "allocation": "each"500 off each unit = 1000
"targetType": "items", "allocation": "across"500 total, split 250 + 250
"targetType": "order", any allocation500 total (order + fixed is always across)

Add "maxQuantity": 1 to each to discount only the most expensive unit.

Coupon-only campaign: "requiresCoupon": true, "conditions": { "combinator": "AND", "conditions": [] } (empty means always eligible), any application method. Then bulk-generate single-use codes with "maxUses": 1.

  • Dollars instead of cents. rightValue: 50 means $0.50.
  • A future validFrom on a preview is pre-filtered as not_yet_active even with includeInactiveIds.
  • Matching on item metadata does nothing; use attributes or tags.
  • exclusive removes stackable promotions too. Use exclusive_group to make only a set of promotions compete.
  • Recording re-evaluates the cart. It does not reuse the preview result, so the recorded discount reflects the state at record time. A retry with the same transactionId returns the first recorded result; a changed body for that transactionId is a conflict.