Promotion Scenarios & Action Types
This page is a developer-facing reference for the promotion scenarios the Digital Retail Engine supports, the action types behind them, and — most importantly for integrators — where each one shows up in the POS v2 evaluate response. Every action below includes a request/response pair so you can see exactly what to send and what comes back.
What is a "scenario"?
A scenario is the combination an author picks in the admin UI when building a promotion:
- a promotion type (
PromotionType:ARTICLE,RECEIPT,LOYALTY,BUNDLE,COUPON), which selects the trigger and reward family; plus - one or more actions attached to the promotion, each with an action
type (
ActionTypefor discounts/give-aways,LoyaltyActionTypefor points).
The action type decides which reward logic runs and which field of the evaluate response carries the result. There are three response surfaces to keep in mind:
| Surface | Carries | Action types that use it |
|---|---|---|
lineItems[].discounts[] (and the line's lineDiscount/lineNet) | Per-line monetary markdown, inline on the line that received it. | ARTICLE, ARTICLE_GROUP, ARTICLE_LIST, RECEIPT, SCALED_RECEIPT, BUNDLE, QUANTITY_TIER, FREE_ITEM (gift branch) |
grantedItems[] | Free articles injected into the basket (not previously scanned). | FREE_ITEM (inject branch) |
totals.savingsSummary.loyaltyPointsEarned (+ the off-wire loyalty breakdown) | Loyalty points earned/spent. Never modifies line prices. | ADD_FIXED, MULTIPLY_POINTS, CURRENCY_TO_POINTS, SUBTRACT_POINTS |
discounts[].promotionType is the parent promotion typeOn the wire, each LineDiscountV2.promotionType carries the parent
PromotionType (promo.type) — ARTICLE, RECEIPT, BUNDLE, … — not the
granular action type. So an ARTICLE_GROUP, ARTICLE_LIST, QUANTITY_TIER, or
FREE_ITEM (gift) discount all surface with promotionType: "ARTICLE"; the
specific action is an authoring detail, not a wire field. The field name and
type of everything referenced here is documented on the
POS Service page — that page is the
wire contract; this page explains behaviour.
The examples below are fragments — the qualifying part of the request and
the part of the response that changes. For one complete end-to-end
EvaluateRequest/EvaluateResponseV2, see the
canonical example on the wire
contract.
Line vs receipt promotions
DRE has two ways a monetary discount reaches the basket, and understanding the difference is the key to reading the response.
- Line (item) promotions attach their discount to specific lines. The
markdown appears directly in
lineItems[].discounts[]on the line(s) that qualified, and that line'slineDiscount/lineNetupdate. Article, article group, article list, bundle, quantity tier, and the free-item gift branch are all line promotions. - Receipt (basket) promotions discount the whole basket total, then
distribute that amount back across the qualifying lines according to a
distributionMode(PROPORTIONAL/EQUAL/HIGHEST_FIRST). They still surface aslineItems[].discounts[]entries — one per line that received a share, taggedpromotionType: "RECEIPT"— and the shares sum to the configured receipt discount.
Why distribute instead of a single basket-level line? It keeps the response
strictly line-centric: every cent of discount is traceable to a line, so a
till can print correct per-line net prices even for a basket-wide offer, and
returns/refunds stay attributable. The only monetary action that does not
touch a line is the post-purchase coupon — it contributes solely to the
promotion-level totalDiscount and the receipt savings.
Scenario reference
| Scenario | Action type | Parent promotion type | Effect surfaces in |
|---|---|---|---|
| Article | ARTICLE | ARTICLE | lineItems[].discounts[] |
| Article Group | ARTICLE_GROUP | ARTICLE | lineItems[].discounts[] |
| Article List | ARTICLE_LIST | ARTICLE | lineItems[].discounts[] |
| Receipt | RECEIPT | RECEIPT | lineItems[].discounts[] (distributed) |
| Scaled Receipt | SCALED_RECEIPT | RECEIPT | lineItems[].discounts[] (distributed) |
| Bundle | BUNDLE | BUNDLE | lineItems[].discounts[] |
| Quantity Tier | QUANTITY_TIER | ARTICLE | lineItems[].discounts[] |
| Free Item | FREE_ITEM | ARTICLE | lineItems[].isFreeItem (gift) or grantedItems[] (inject) |
| Post-Purchase Coupon | POST_PURCHASE_COUPON | COUPON | totals / savings only (issued at confirm) |
| Add Fixed Points | ADD_FIXED | LOYALTY | loyaltyPointsEarned |
| Multiply Points | MULTIPLY_POINTS | LOYALTY | loyaltyPointsEarned |
| Currency to Points | CURRENCY_TO_POINTS | LOYALTY | loyaltyPointsEarned |
| Subtract Points | SUBTRACT_POINTS | LOYALTY | loyaltyPointsEarned |
Discount actions
Article — ARTICLE
Discount on a single article.
- Key config:
discountType(PERCENTAGE/ABSOLUTE/UNIT_PRICE),discountValue, the target article (targetArticle, or free-texttargetArticleNumberin standalone mode), optionalmaxDiscountAmount, optionalselectionType+applicationQuantityMode/applicationQuantityto limit how many qualifying units are rewarded. - Emits: one
LineDiscountV2on each matching line. The line'slineDiscountandlineNetupdate accordingly.
Example — 10% off ART-1001:
{
"request": {
"posGroupCode": "STORE-001",
"items": [
{ "lineReference": "L1", "articleNumber": "ART-1001", "quantity": 2, "unitPrice": 89.99 }
]
}
}
{
"lineReference": "L1",
"lineTotal": { "value": 179.98, "currency": "EUR" },
"lineDiscount": { "value": 18.00, "currency": "EUR" },
"lineNet": { "value": 161.98, "currency": "EUR" },
"discounts": [
{
"promotionName": "Electronics 10% Off",
"promotionType": "ARTICLE",
"discountType": "PERCENTAGE",
"discountValue": 10,
"totalDiscount": { "value": 18.00, "currency": "EUR" }
}
]
}
Article Group — ARTICLE_GROUP
Discount on every article belonging to a group.
- Key config:
discountType,discountValue,targetArticleGroup(or free-texttargetArticleGroupIdin standalone mode); same selection/quantity controls asARTICLE. - Emits: a
LineDiscountV2on each line whose article is in the group.
Example — 15% off everything in group BEVERAGES:
{
"request": {
"posGroupCode": "STORE-001",
"items": [
{ "lineReference": "L1", "articleNumber": "COLA-05", "articleGroupId": "BEVERAGES", "quantity": 3, "unitPrice": 1.20 }
]
}
}
{
"lineReference": "L1",
"lineDiscount": { "value": 0.54, "currency": "EUR" },
"discounts": [
{
"promotionType": "ARTICLE",
"discountType": "PERCENTAGE",
"discountValue": 15,
"totalDiscount": { "value": 0.54, "currency": "EUR" }
}
]
}
Article List — ARTICLE_LIST
Discount on an inline list of article numbers, with an optional fixed-price override per article.
- Key config:
articleListItems[]— each entry is{ articleNumber, ean, fixedPrice? }. AfixedPriceturns the entry into a "set this article to price X" override; otherwise the action-leveldiscountType/discountValueapplies. - Emits: a
LineDiscountV2on each line whose article appears in the list. With a fixed price,discountTypeis effectivelyUNIT_PRICE.
Example — ART-1001 fixed to €79.00 (was €89.99):
{
"request": {
"posGroupCode": "STORE-001",
"items": [
{ "lineReference": "L1", "articleNumber": "ART-1001", "quantity": 1, "unitPrice": 89.99 }
]
}
}
{
"lineReference": "L1",
"lineNet": { "value": 79.00, "currency": "EUR" },
"discounts": [
{
"promotionType": "ARTICLE",
"discountType": "UNIT_PRICE",
"discountValue": 79.00,
"totalDiscount": { "value": 10.99, "currency": "EUR" }
}
]
}
Receipt — RECEIPT
Flat discount on the whole basket total.
- Key config:
discountType,discountValue, anddistributionMode(PROPORTIONAL/EQUAL/HIGHEST_FIRST) controlling how the basket-level discount is spread across lines. - Emits:
LineDiscountV2entries withpromotionType: "RECEIPT", distributed across qualifying lines perdistributionMode. The shares sum to the configured receipt discount;totals.discountreflects it.
Example — €10 off the basket, distributed proportionally across two lines (€60 + €40 ⇒ €6.00 + €4.00):
{
"request": {
"posGroupCode": "STORE-001",
"items": [
{ "lineReference": "L1", "articleNumber": "ART-A", "quantity": 1, "unitPrice": 60.00 },
{ "lineReference": "L2", "articleNumber": "ART-B", "quantity": 1, "unitPrice": 40.00 }
]
}
}
{
"lineItems": [
{
"lineReference": "L1",
"discounts": [
{ "promotionType": "RECEIPT", "discountType": "ABSOLUTE", "totalDiscount": { "value": 6.00, "currency": "EUR" } }
]
},
{
"lineReference": "L2",
"discounts": [
{ "promotionType": "RECEIPT", "discountType": "ABSOLUTE", "totalDiscount": { "value": 4.00, "currency": "EUR" } }
]
}
],
"totals": {
"discount": { "value": 10.00, "currency": "EUR" }
}
}
Scaled Receipt — SCALED_RECEIPT
Tiered receipt discount — the higher the basket total, the higher the discount.
- Key config:
scaledTiers[]— each tier is{ thresholdAmount, discountType, discountValue }. The engine selects the highest tier whosethresholdAmountthe basket total meets, then applies that tier's discount (distributed likeRECEIPT). - Emits: distributed
LineDiscountV2entries. When the basket is below the first threshold, this is a classic near-miss source — see thethresholdGaps[]block (typeSCALED_RECEIPT) andrecommendations[].
Example — tiers {≥50 → 5%, ≥100 → 10%}; a €120 basket hits the 10% tier,
while a €42 basket emits a near-miss instead:
{
"request": {
"posGroupCode": "STORE-001",
"items": [
{ "lineReference": "L1", "articleNumber": "ART-A", "quantity": 1, "unitPrice": 42.00 }
]
}
}
{
"thresholdGaps": [
{
"promotionName": "Spend & Save",
"type": "SCALED_RECEIPT",
"currentValue": 42.00,
"threshold": 50.00,
"gap": 8.00,
"potentialSaving": { "value": 2.50, "currency": "EUR" }
}
]
}
Bundle — BUNDLE
Discount when a defined combination of articles is present in the basket.
- Key config:
bundleComponents[]— each component is{ articleNumber, minQuantity (default 1), maxQuantity? }. The engine greedily forms a bundle when all components are satisfied; any unmet component blocks formation.maxBundlescaps how many bundles can form per basket. - Emits:
LineDiscountV2entries (promotionType: "BUNDLE") on the lines that make up each formed bundle.
Example — buy a phone + case, get €15 off (split across the bundle lines):
{
"request": {
"posGroupCode": "STORE-001",
"items": [
{ "lineReference": "L1", "articleNumber": "PHONE-X", "quantity": 1, "unitPrice": 699.00 },
{ "lineReference": "L2", "articleNumber": "CASE-X", "quantity": 1, "unitPrice": 29.00 }
]
}
}
{
"lineItems": [
{
"lineReference": "L1",
"discounts": [
{ "promotionType": "BUNDLE", "discountType": "ABSOLUTE", "totalDiscount": { "value": 14.38, "currency": "EUR" } }
]
},
{
"lineReference": "L2",
"discounts": [
{ "promotionType": "BUNDLE", "discountType": "ABSOLUTE", "totalDiscount": { "value": 0.62, "currency": "EUR" } }
]
}
]
}
Quantity Tier — QUANTITY_TIER
Volume-tiered unit pricing on a single dedicated code (or article group): the aggregate quantity selects the highest qualifying price tier, and the tier price applies to all units of that code.
- Parent type:
ARTICLE. This action is value-exempt — it carries no parentdiscountValue; the per-tier prices live on the tiers. - Key config: exactly one of
targetArticleNumber(single code) ortargetArticleGroupId(group;includeSubGroupsoptionally counts descendant groups) — XOR, enforced in the engine.quantityTiers[]— each tier is{ minQuantity, discountType, discountValue }. The engine aggregates the basket quantity of the dedicated code across all its lines, resolves the single highest qualifying tier, and applies that tier's per-unit value to each line. - Emits: a
LineDiscountV2on each line of the dedicated code. The resolved tier is off-wire (it rides to the side-effects worker via the persistence snapshot, never exposed as alineItemsfield).
Example — WATER-1L priced at €0.80/unit from 6 units (here 8 units across
two lines all get the tier price):
{
"request": {
"posGroupCode": "STORE-001",
"items": [
{ "lineReference": "L1", "articleNumber": "WATER-1L", "quantity": 6, "unitPrice": 0.99 },
{ "lineReference": "L2", "articleNumber": "WATER-1L", "quantity": 2, "unitPrice": 0.99 }
]
}
}
{
"lineItems": [
{
"lineReference": "L1",
"discounts": [
{ "promotionType": "ARTICLE", "discountType": "UNIT_PRICE", "discountValue": 0.80, "totalDiscount": { "value": 1.14, "currency": "EUR" } }
]
},
{
"lineReference": "L2",
"discounts": [
{ "promotionType": "ARTICLE", "discountType": "UNIT_PRICE", "discountValue": 0.80, "totalDiscount": { "value": 0.38, "currency": "EUR" } }
]
}
]
}
Free Item — FREE_ITEM
Grants a free article when conditions match. Two branches, each with a different response surface.
- Parent type:
ARTICLE. Also value-exempt (no parentdiscountValue). - Key config:
freeItemArticleNumber(the article to give away),freeItemQuantity(default1),restrictToOnePerBasket(defaulttrue),freeItemReferencePrice(used to value the give-away when no basket/master-data price is available), and the optional capmaxFreeUnits.
GIFT branch — the free article is already in the basket: that line is
zero-priced in place, flagged isFreeItem: true with freeItemPromotionId, and
carries a LineDiscountV2 whose discountType is FREE_ITEM. lineItems stays
strictly 1:1 with input.
{
"request": {
"posGroupCode": "STORE-001",
"items": [
{ "lineReference": "L1", "articleNumber": "GIFT-MUG", "quantity": 1, "unitPrice": 7.50 }
]
}
}
{
"lineReference": "L1",
"lineNet": { "value": 0.00, "currency": "EUR" },
"isFreeItem": true,
"freeItemPromotionId": "20000000-0000-4000-8000-000000000007",
"discounts": [
{ "promotionType": "ARTICLE", "discountType": "FREE_ITEM", "totalDiscount": { "value": 7.50, "currency": "EUR" } }
]
}
INJECT branch — the free article is not in the basket: it is emitted as a
GrantedItemV2 in grantedItems[]. The injected give-away is off-subtotal — it
does not reduce totals.discount.
{
"grantedItems": [
{
"grantReference": "GRANT-2GIFT-MUG-1",
"articleNumber": "GIFT-MUG",
"quantity": 1,
"referencePrice": { "value": 7.50, "currency": "EUR" },
"priceSource": "MASTER_DATA",
"giveAwayValue": { "value": 7.50, "currency": "EUR" },
"promotionName": "Free mug over €50"
}
]
}
Post-Purchase Coupon — POST_PURCHASE_COUPON
Issues a coupon for a future purchase. The coupon code is generated at
/v2/confirm time, not during evaluate/simulate.
- Parent type:
COUPON. - Key config:
targetCouponType(theCouponTypetemplate to generate codes from); the voucher face value is stored on the action'sdiscountValue. - Emits during evaluate: the face value is attributed as the promotion's
totalDiscount(so the receipt shows "you earned a voucher worth X"). It surfaces only viatotals.savingsSummary— it discounts no in-cart line. - Emits during confirm: the generated coupon codes appear in the
side-effects poll under
postPurchaseCoupons[].
{
"request": {
"posGroupCode": "STORE-001",
"items": [
{ "lineReference": "L1", "articleNumber": "ART-A", "quantity": 1, "unitPrice": 60.00 }
],
"customer": { "customerId": "CUST-4711" }
}
}
{
"totals": {
"savingsSummary": {
"promotionBreakdown": [
{
"promotionName": "Spend €50, get a €5 voucher",
"totalDiscount": { "value": 5.00, "currency": "EUR" },
"affectedItems": []
}
]
}
}
}
At confirm, the code surfaces in the side-effects poll:
{
"postPurchaseCoupons": [
{ "code": "VOUCHER-7F3K9A", "customerId": "CUST-4711", "issuedAt": "2026-06-07T14:30:45.880Z" }
]
}
Loyalty actions
Loyalty promotions (PromotionType: LOYALTY) never modify basket prices.
Each loyalty action contributes to the points result, aggregated into
totals.savingsSummary.loyaltyPointsEarned. All loyalty actions share a
targetScope (ARTICLE / ARTICLE_GROUP / ARTICLE_LIST / ALL_ITEMS,
default ALL_ITEMS) selecting which items contribute.
The request shape is the same for all four — a basket plus a customer with
loyalty context — so it is shown once:
{
"request": {
"posGroupCode": "STORE-001",
"items": [
{ "lineReference": "L1", "articleNumber": "ART-A", "quantity": 1, "unitPrice": 100.00 }
],
"customer": { "customerId": "CUST-4711", "loyalty": { "tier": "GOLD", "points": 1250 } }
}
}
Add Fixed Points — ADD_FIXED
Awards a fixed number of bonus points.
- Key config:
pointsValue— the fixed number of points to add.
{ "totals": { "savingsSummary": { "loyaltyPointsEarned": 500 } } }
Multiply Points — MULTIPLY_POINTS
Multiplies the base points (1 point per currency unit) earned on qualifying
items by a factor. Free units (e.g. FREE_ITEM give-aways) are excluded from
the base.
- Key config:
multiplier.
{ "totals": { "savingsSummary": { "loyaltyPointsEarned": 200 } } }
(€100 base = 100 points, ×2 ⇒ 200.)
Currency to Points — CURRENCY_TO_POINTS
Converts qualifying spend into points at a configured rate. Points use floor rounding.
- Key config:
conversionRate(e.g.1.5= 1.5 points per unit of currency).
{ "totals": { "savingsSummary": { "loyaltyPointsEarned": 150 } } }
(€100 × 1.5, floored ⇒ 150.)
Subtract Points — SUBTRACT_POINTS
Deducts points (pay-with-points). The deduction reduces the net
loyaltyPointsEarned. If the customer's balance is insufficient, the action is
skipped silently (no error).
- Key config:
pointsValue— the number of points to deduct.
{ "totals": { "savingsSummary": { "loyaltyPointsEarned": -200 } } }
Related pages
- POS Service — the v2 wire contract (request/response field tables and the canonical full example).
- Evaluate → Confirm lifecycle — how these results are committed.
- Coupon lifecycle — the post-purchase coupon flow in full.
- Recommendation Hint Catalog — the
codevalues used inrecommendations[].