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.
1. Create the promotion
Section titled “1. Create the promotion”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').subtotalis calculated by the engine from item prices, so a client cannot inflate it.acrossrounds once for the whole order.eachwould round 10% separately on every unit.- Set
validFromonly after you finish previewing, or set it in the past. A futurevalidFrompre-filters the promotion with reasonnot_yet_active, even on a dry run.
2. Preview the draft
Section titled “2. Preview the draft”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 } ] }}dryRundefaults totrue. A dry run never consumes uses, budget, coupons or wallet points.includeInactiveIdslets the listed non-active promotions through the status check while every other promotion must still beactive. This previews the draft against the live campaigns it will compete with.includeInactive: trueadmits every non-active promotion.- Both flags work on dry runs only.
includeInactiveIdsis rejected withdryRun: false, andincludeInactiveis 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": "…" }] }reason | Meaning | Fix |
|---|---|---|
conditions_not_met | A condition failed; see failedConditions | Check the expression path and units |
computation_no_discount | Conditions passed but no unit qualified, or a script returned no valid items | Check targetRules / script output |
computation_error | The script threw or timed out | Test it with execute-script |
invalid_conditions | The stored condition group is malformed | Re-save conditions |
excluded_by_stacking | Another promotion won stacking | Check priority/stackingMode; trace[].excludedBy names the winner |
coupon_required | requiresCoupon and no valid code for it | Read warnings for the coupon error |
inactive, expired, depleted | Status (or validTo passed). status is included when the cause is the status | Activate, or extend the dates |
not_yet_active | Before validFrom; startsAt is given | |
capacity_exhausted, budget_exhausted | maxTotalUses, 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.
3. Activate
Section titled “3. Activate”PATCH /projects/{projectId}/promotions/{promotionId} with { "status": "active" }.
4. Record the checkout
Section titled “4. Record the checkout”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.
5. Cancel a usage
Section titled “5. Cancel a usage”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).
Coupons
Section titled “Coupons”| Action | Endpoint | Body |
|---|---|---|
| Create one | POST /projects/{projectId}/promotions/{promotionId}/coupons | { "code": "WELCOME10", "maxUses": 500, "maxUsesPerUser": 1, "validTo": "2026-12-31T23:59:59Z" } |
| Generate many | POST …/{promotionId}/coupons/bulk | { "count": 200, "prefix": "VIP", "maxUses": 1 } gives codes like VIP-7K2M9QXA |
| Look up | GET /projects/{projectId}/promotions/coupons/lookup/{code} | 404 COUPON_NOT_FOUND if missing |
| Update / delete | PATCH / 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 generatesPROMO-XXXXXXXX. countmust be between 1 and 1000.- A coupon whose
maxUsesis reached is set toexhausted. - Pass codes as
couponCodes: ["WELCOME10"]on evaluate. Unknown, expired or used-up codes produce entries inwarnings(for exampleCoupon code "X" not found), not innotApplied.
Recipes
Section titled “Recipes”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 allocation | 500 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.
Pitfalls
Section titled “Pitfalls”- Dollars instead of cents.
rightValue: 50means $0.50. - A future
validFromon a preview is pre-filtered asnot_yet_activeeven withincludeInactiveIds. - Matching on item
metadatadoes nothing; useattributesortags. exclusiveremoves stackable promotions too. Useexclusive_groupto 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
transactionIdreturns the first recorded result; a changed body for thattransactionIdis a conflict.
See also
Section titled “See also”- Promotion concepts
- Conditions
- Discount scripts
- Checkout integration
- Promotions, Promotions - Coupons, Promotions - Usage in the API reference