Skip to content

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.

{
"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).

FieldRequiredNotes
currencyCodeYesEchoed in summary.currencyCode
items[]Yes, at least 1See below
userIdFor per-user limits; required when dryRun: falseAlso 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
customerNo{ id, email, groupIds, segments, tier, tags, orderCount, totalSpent, firstOrderDate, lastOrderDate, daysSinceLastOrder, metadata }
contextNoreferrer, 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, regionNoCopied to $('cart')
shippingAddress, billingAddress, locale, metadataNoAccepted; not exposed to conditions
subtotal, shippingTotal, taxTotalNosubtotal is ignored. taxTotal is used as sent. shippingTotal is used only when no shippingMethods are sent
FieldRequiredNotes
idYesThe line id. Discounts and items[].itemId refer to it. Keep it unique per line and stable between preview and record
quantityYesInteger ≥ 1. The engine expands each line into this many units
unitPriceYesInteger cents ≥ 0, before discounts. A decimal such as 25.00 is rejected
productId, variantId, skuNoProduct identity for targetRules. Several lines can share a productId
category, categories, collections, brand, tagsNoLabels, matched case-insensitively
attributesNo{ 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
weightNo{ value, unit }. Effective unit price = round(unitPrice × value)
compareAtPrice, isGift, isPreOrder, isSubscription, bundleIdNoExposed to rules
metadata, properties, requiresShipping, bundleIndexNoAccepted; 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.

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

{ "success": true, "data": { ... } } with:

  • items[]: one entry per discounted line, with lineIndex, itemId, quantity, unitPrice, originalLineTotal, discountTotal, adjustedUnitPrice, lineTotal and adjustments[{ promotionId, promotionName, amount, couponCode? }]. Charge lineTotal. adjustedUnitPrice is rounded and may not multiply back exactly.
  • shippingMethods[]: per-method originalAmount, discountTotal, adjustedAmount. Present only when shipping was discounted.
  • summary: itemsOriginalTotal, itemsDiscountTotal, shippingOriginalTotal, shippingDiscountTotal, discountTotal, total (items + shipping − discounts; excludes tax), savingsPercentage, appliedCount.
  • applied[], notApplied[] (with reason codes), 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 }], plus duplicate: true on a replay.
PreviewRecord
BodydryRun omitted or truedryRun: false, transactionId, cart.userId, optional orderId
Side effectsNoneUsage record; promotion totalUses/budgetUsed; per-user count; coupon uses; wallet spend
Per-user limitsEnforced (needs userId)Enforced
Draft preview flagsAllowedincludeInactiveIds → 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.

Use one transactionId per checkout attempt, for example your payment intent or order id, and send the same body on every retry.

SituationResult
First record200; 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 difference409 TRANSACTION_CONFLICT
transactionId of a cancelled usage409 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.redemptions400 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.

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 by promotionId, userId, orderId, transactionId or status, with page and pageSize (max 100).
  • GET /projects/{projectId}/promotions/usage/{usageId} and GET /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 409 ALREADY_CANCELLED if already cancelled and 409 CHECKOUT_PENDING while a checkout is still pending.
  • 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: true and that GET …/promotions/{promotionId}/stats counted one use.
  • Change one field and replay; expect 409 TRANSACTION_CONFLICT.