Skip to main content

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:

  1. a promotion type (PromotionType: ARTICLE, RECEIPT, LOYALTY, BUNDLE, COUPON), which selects the trigger and reward family; plus
  2. one or more actions attached to the promotion, each with an action type (ActionType for discounts/give-aways, LoyaltyActionType for 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:

SurfaceCarriesAction 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
info
discounts[].promotionType is the parent promotion type

On 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's lineDiscount/lineNet update. 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 as lineItems[].discounts[] entries — one per line that received a share, tagged promotionType: "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

ScenarioAction typeParent promotion typeEffect surfaces in
ArticleARTICLEARTICLElineItems[].discounts[]
Article GroupARTICLE_GROUPARTICLElineItems[].discounts[]
Article ListARTICLE_LISTARTICLElineItems[].discounts[]
ReceiptRECEIPTRECEIPTlineItems[].discounts[] (distributed)
Scaled ReceiptSCALED_RECEIPTRECEIPTlineItems[].discounts[] (distributed)
BundleBUNDLEBUNDLElineItems[].discounts[]
Quantity TierQUANTITY_TIERARTICLElineItems[].discounts[]
Free ItemFREE_ITEMARTICLElineItems[].isFreeItem (gift) or grantedItems[] (inject)
Post-Purchase CouponPOST_PURCHASE_COUPONCOUPONtotals / savings only (issued at confirm)
Add Fixed PointsADD_FIXEDLOYALTYloyaltyPointsEarned
Multiply PointsMULTIPLY_POINTSLOYALTYloyaltyPointsEarned
Currency to PointsCURRENCY_TO_POINTSLOYALTYloyaltyPointsEarned
Subtract PointsSUBTRACT_POINTSLOYALTYloyaltyPointsEarned

Discount actions

Article — ARTICLE

Discount on a single article.

  • Key config: discountType (PERCENTAGE / ABSOLUTE / UNIT_PRICE), discountValue, the target article (targetArticle, or free-text targetArticleNumber in standalone mode), optional maxDiscountAmount, optional selectionType + applicationQuantityMode / applicationQuantity to limit how many qualifying units are rewarded.
  • Emits: one LineDiscountV2 on each matching line. The line's lineDiscount and lineNet update 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-text targetArticleGroupId in standalone mode); same selection/quantity controls as ARTICLE.
  • Emits: a LineDiscountV2 on 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? }. A fixedPrice turns the entry into a "set this article to price X" override; otherwise the action-level discountType/discountValue applies.
  • Emits: a LineDiscountV2 on each line whose article appears in the list. With a fixed price, discountType is effectively UNIT_PRICE.

ExampleART-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, and distributionMode (PROPORTIONAL / EQUAL / HIGHEST_FIRST) controlling how the basket-level discount is spread across lines.
  • Emits: LineDiscountV2 entries with promotionType: "RECEIPT", distributed across qualifying lines per distributionMode. The shares sum to the configured receipt discount; totals.discount reflects 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 whose thresholdAmount the basket total meets, then applies that tier's discount (distributed like RECEIPT).
  • Emits: distributed LineDiscountV2 entries. When the basket is below the first threshold, this is a classic near-miss source — see the thresholdGaps[] block (type SCALED_RECEIPT) and recommendations[].

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. maxBundles caps how many bundles can form per basket.
  • Emits: LineDiscountV2 entries (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 parent discountValue; the per-tier prices live on the tiers.
  • Key config: exactly one of targetArticleNumber (single code) or targetArticleGroupId (group; includeSubGroups optionally 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 LineDiscountV2 on 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 a lineItems field).

ExampleWATER-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 parent discountValue).
  • Key config: freeItemArticleNumber (the article to give away), freeItemQuantity (default 1), restrictToOnePerBasket (default true), freeItemReferencePrice (used to value the give-away when no basket/master-data price is available), and the optional cap maxFreeUnits.

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 (the CouponType template to generate codes from); the voucher face value is stored on the action's discountValue.
  • 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 via totals.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 } } }