Integrating Checkout Promotions
Your backend sends the basket to one endpoint, POST /projects/{projectId}/promotions/evaluate, at two points. While the customer shops, it sends a preview (dryRun: true, the default), which changes nothing. At order placement it sends a record (dryRun: false + transactionId), which consumes uses, budget, coupons and wallet assets. Both return the same response. Call it from your server with your project API key; a browser should never send prices to it.
Request
Section titled “Request”{ "dryRun": true, "couponCodes": ["WELCOME10"], "cart": { "currencyCode": "USD", "userId": "user_42", "items": [ { "id": "line-1", "productId": "prod_jacket", "variantId": "jacket-m-blue", "sku": "JKT-M-BL", "quantity": 1, "unitPrice": 8900, "category": "Outerwear", "tags": ["winter"], "attributes": { "material": "wool" } }, { "id": "line-2", "productId": "prod_socks", "quantity": 3, "unitPrice": 900, "modifiers": [{ "id": "gift-wrap", "name": "Gift wrap", "unitPrice": 200 }] } ], "shippingMethods": [{ "id": "std", "name": "Standard", "amount": 599, "type": "standard" }], "customer": { "id": "user_42", "tier": "gold", "orderCount": 3 }, "context": { "utmSource": "newsletter", "timezone": "Europe/Berlin" }, "redemptions": [{ "type": "points", "amount": 500 }] }}Top-level fields: cart (required), couponCodes, promotionIds (evaluate only these), dryRun, transactionId, orderId, includeInactive / includeInactiveIds (dry run only; see creating promotions).
Cart fields
Section titled “Cart fields”| Field | Required | Notes |
|---|---|---|
currencyCode | Yes | Echoed in summary.currencyCode |
items[] | Yes, at least 1 | See below |
userId | For per-user limits; required when dryRun: false | Also becomes $('customer').id when customer is absent, and is whose wallet is spent |
shippingMethods[] | For shipping discounts | { id, amount, name?, carrier?, type?, metadata? }, with amount in cents |
customer | No | { id, email, groupIds, segments, tier, tags, orderCount, totalSpent, firstOrderDate, lastOrderDate, daysSinceLastOrder, metadata } |
context | No | referrer, utm*, device, ipCountry, timezone, isFirstOrder, …; extra keys are allowed |
redemptions[] | No | { type, amount } (positive integers): the most wallet assets a script may spend |
salesChannel, storeId, region | No | Copied to $('cart') |
shippingAddress, billingAddress, locale, metadata | No | Accepted; not exposed to conditions |
subtotal, shippingTotal, taxTotal | No | subtotal is ignored. taxTotal is used as sent. shippingTotal is used only when no shippingMethods are sent |
Item fields
Section titled “Item fields”| Field | Required | Notes |
|---|---|---|
id | Yes | The line id. Discounts and items[].itemId refer to it. Keep it unique per line and stable between preview and record |
quantity | Yes | Integer ≥ 1. The engine expands each line into this many units |
unitPrice | Yes | Integer cents ≥ 0, before discounts. A decimal such as 25.00 is rejected |
productId, variantId, sku | No | Product identity for targetRules. Several lines can share a productId |
category, categories, collections, brand, tags | No | Labels, matched case-insensitively |
attributes | No | { key: string | number | boolean }. Use this for anything a rule must match |
modifiers[] | No | { id, name, unitPrice, quantity?, group?, sku? }, added to the unit price |
weight | No | { value, unit }. Effective unit price = round(unitPrice × value) |
compareAtPrice, isGift, isPreOrder, isSubscription, bundleId | No | Exposed to rules |
metadata, properties, requiresShipping, bundleIndex | No | Accepted; not exposed to rules or scripts |
Use id for the line and productId for the product. If two lines share an id, you cannot tell their discounts apart; use lineIndex in the response to map them back.
The request validator accepts any object as cart; the engine checks the cart itself. An invalid cart (missing currencyCode, a non-integer unitPrice, no items) currently returns HTTP 500 INTERNAL_ERROR, with a message beginning Invalid cart format. that lists each failing path. Validate carts on your side and do not retry these. A cart whose items use price instead of unitPrice is accepted in a deprecated legacy format and returns a DEPRECATED warning; migrate it.
What the engine derives
Section titled “What the engine derives”The engine recomputes subtotal (sum of unit prices including modifiers, over all units), shippingTotal, total, item counts and label unions. It caps total item discounts at that subtotal. It resolves the promotion’s resources for cart.userId and builds $('time') from context.timezone. Full list: what expressions can read. Nothing is looked up from the catalog. The labels and attributes on each item are all the engine knows (see catalog).
Response
Section titled “Response”{ "success": true, "data": { ... } } with:
items[]: one entry per discounted line, withlineIndex,itemId,quantity,unitPrice,originalLineTotal,discountTotal,adjustedUnitPrice,lineTotalandadjustments[{ promotionId, promotionName, amount, couponCode? }]. ChargelineTotal.adjustedUnitPriceis rounded and may not multiply back exactly.shippingMethods[]: per-methodoriginalAmount,discountTotal,adjustedAmount. Present only when shipping was discounted.summary:itemsOriginalTotal,itemsDiscountTotal,shippingOriginalTotal,shippingDiscountTotal,discountTotal,total(items + shipping − discounts; excludes tax),savingsPercentage,appliedCount.applied[],notApplied[](withreasoncodes),trace[],warnings[](coupon problems, budget clamps, legacy format).redemptions[]: wallet assets that a recorded checkout spends (or that a preview would spend).- On a record:
usages: [{ id, transactionId }], plusduplicate: trueon a replay.
Preview vs record
Section titled “Preview vs record”| Preview | Record | |
|---|---|---|
| Body | dryRun omitted or true | dryRun: false, transactionId, cart.userId, optional orderId |
| Side effects | None | Usage record; promotion totalUses/budgetUsed; per-user count; coupon uses; wallet spend |
| Per-user limits | Enforced (needs userId) | Enforced |
| Draft preview flags | Allowed | includeInactiveIds → 400; includeInactive ignored |
The record call evaluates the cart again; it does not reuse the last preview. Show the customer the record response’s totals.
Retries and transactionId
Section titled “Retries and transactionId”Use one transactionId per checkout attempt, for example your payment intent or order id, and send the same body on every retry.
| Situation | Result |
|---|---|
| First record | 200; usage created |
Same transactionId, same body, userId and orderId (key order ignored) | 200 with the stored response and duplicate: true. Nothing is consumed again, and a pending wallet spend is completed |
Same transactionId, any other difference | 409 TRANSACTION_CONFLICT |
transactionId of a cancelled usage | 409 TRANSACTION_CANCELLED: use a new id |
| A promotion or coupon became ineligible between evaluation and commit (cap, budget, status, dates) | 409 CHECKOUT_STALE: re-preview and confirm the new price |
Evaluated redemption exceeds cart.redemptions | 400 REDEMPTION_NOT_AUTHORIZED |
Request body fails validation (e.g. dryRun: false without transactionId or cart.userId) | 400 |
After a timeout, look up the transaction first (GET /projects/{projectId}/promotions/usage?transactionId=…), or simply retry with the identical body. A checkout to which no promotion applied still creates a usage record; it serves as the replay receipt.
Usage records
Section titled “Usage records”Each recorded checkout is one usage with id, transactionId, userId, orderId, totalDiscount, status (pending → active, or cancelled), appliedAt and items[{ promotionId, couponId, discountAmount }].
GET /projects/{projectId}/promotions/usage: filter bypromotionId,userId,orderId,transactionIdorstatus, withpageandpageSize(max 100).GET /projects/{projectId}/promotions/usage/{usageId}andGET /projects/{projectId}/promotions/{promotionId}/usage.POST /projects/{projectId}/promotions/usage/{usageId}/cancel: on a refund, reverses use, budget and coupon counters. It does not refund wallet assets. Returns 409ALREADY_CANCELLEDif already cancelled and 409CHECKOUT_PENDINGwhile a checkout is stillpending.
Verify
Section titled “Verify”- Preview a cart just under and just over each threshold. Check
notApplied[].failedConditions[].actual. - Record twice with the same body and confirm the second response has
duplicate: trueand thatGET …/promotions/{promotionId}/statscounted one use. - Change one field and replay; expect 409
TRANSACTION_CONFLICT.
See also
Section titled “See also”- Promotion concepts
- Creating promotions
- Discount scripts
- Catalog
- Promotions, Promotions - Coupons, Promotions - Usage in the API reference