POS Service
The POS Service (/pos) provides endpoints consumed by POS terminals and mobile apps for real-time promotion evaluation, simulation, coupon management, article import, and transaction confirmation.
Overview
| Property | Value |
|---|---|
| Base Path | /pos |
| Protocol | Dual-protocol — @protocol: ['odata-v4', 'rest'] |
| Evaluation wire contract | v2 (EvaluateResponseV2, body minorVersion: 8) |
| Method | POST (evaluate / simulate / confirm / coupon ops / article import); GET (side-effects poll, heartbeat) |
| Auth | Admin or POSReader role |
POSService is mounted with @protocol: ['odata-v4', 'rest'], so every action is reachable both as a plain REST call (the form documented here, e.g. POST /pos/v2/evaluate) and as an OData V4 unbound action (POST /pos/evaluate with the OData action-invocation envelope). The REST paths below are the canonical, documented integration surface. The documented /v2/... URLs are served via URL aliasing in the service bootstrap — CAP 8.x does not honour @rest.path directly, so the annotation is retained for documentation purposes.
The evaluate/simulate response is line-item-centric (v2). The legacy v1 shape (appliedPromotions[], discountedItems[], a flat totals.totalDiscount, top-level isSimulation/potentialSavings) has been removed — it no longer exists on the wire. If your integration still reads those fields, migrate to the v2 fields described below.
Promotion Evaluation
POST /pos/v2/evaluate
Evaluates a shopping basket against all active promotions and returns the line-item-centric EvaluateResponseV2: each promotion's effect lives inline on the line it touched (lineItems[].discounts[]), plus basket totals, a savings summary, granted free items, coupon results, and recommendations.
The entire payload is wrapped in a top-level request object.
Request — EvaluateRequest
| Field | Type | Required | Notes |
|---|---|---|---|
header | RequestHeader | optional | Iteration identity (see below). |
posGroupId | UUID string | conditional | POS group identifier. At least one of posGroupId / posGroupCode is required. |
posGroupCode | string(20) | conditional | Human-readable POS group code; the handler resolves it to posGroupId. |
items | BasketItem[] | yes | Scanned basket lines (see below). Must be non-empty. |
customer | CustomerInfo | optional | Customer / loyalty context. |
coupons | CouponInput[] | optional | Presented coupon codes — each entry is { "code": "…" }. |
timestamp | ISO-8601 | optional | Transaction time; defaults to server time. |
channel | string(50) | optional | Channel for CHANNEL conditions (e.g. IN_STORE, ONLINE, MOBILE); basket-level, case-insensitive. |
paymentInfo | PaymentInfo | optional | { paymentMeans, cardType, cardNumber } for payment conditions. |
userDefinedFields | KeyValuePair[] | optional | Basket-level key-value pairs for USER_DEFINED conditions. |
manualDiscount | ManualDiscountInfo | optional | { enabled, reasonCode } for MANUAL_DISCOUNT conditions. |
includeInactive | boolean | optional | Simulation only — also evaluate INACTIVE promotions. Default false. |
includeMissedPromotions | boolean | optional | Simulator-only diagnostic — adds a missedPromotions[] array enumerating filtered-out promotions per pipeline stage. Default false; the field is omitted from the response entirely when not set. |
RequestHeader — iteration identity, echoed back on meta.header so the POS can correlate request → response → audit across the (tenantId, transactionId, transactionCounter) tuple:
| Field | Type | Notes |
|---|---|---|
transactionId | string(50) | Optional on /v2/evaluate and /v2/simulate — if omitted, the server generates a UUID and returns it in meta.header.transactionId; the POS must echo it on follow-up calls. Required on /v2/confirm. |
transactionCounter | integer | Server-assigned, 1-indexed per (tenantId, transactionId). The POS must not set it on evaluate/simulate; it is returned on meta.header. Required on /v2/confirm. |
receiptId | string(50) | Optional customer-facing receipt number; echoed in meta.header. |
headerReference | string(100) | Optional opaque basket-level correlation ID; echoed verbatim. |
BasketItem — one scanned article:
| Field | Type | Required | Notes |
|---|---|---|---|
articleNumber | string(50) | yes | SKU / article identifier. |
quantity | Decimal(15,3) | yes | > 0 = sale line, < 0 = return/refund line, = 0 is rejected with HTTP 400. |
unitPrice | Decimal(15,2) | yes | Price per unit before discounts. Echoed unverified — refund-amount correctness and returnability are the POS/ERP's responsibility. |
ean | string(18) | optional | Barcode. |
articleGroup | string(100) | optional | Free-text product group (legacy). |
articleGroupId | string(20) | optional | Business identifier of the article group (matches ArticleGroups.articleGroupId). |
manufacturerId | string(255) | optional | For MANUFACTURER conditions. |
lineReference | string(50) | optional, recommended | Stable per-line reference. Auto-assigned if absent; used to correlate every response line (and affectedItems) back to the scanned item. |
additionalFields | KeyValuePair[] | optional | Per-item key-value pairs for ADDITIONAL_FIELD conditions. |
CustomerInfo: customerId, customerGroup, loyaltyCardNo, loyalty ({ tier, points }).
CouponInput: { code }.
Response — EvaluateResponseV2
Top-level structure:
| Field | Type | Notes |
|---|---|---|
minorVersion | integer | Body-level minor version. Currently 8. Additive shape changes bump this; the major version lives in the URL (/v2/). |
meta | MetaV2 | Response metadata + header echo. |
lineItems | LineItemV2[] | Strictly 1:1 with input items — every line echoed with its inline discount stack. |
grantedItems | GrantedItemV2[] | Free items injected by FREE_ITEM promotions (articles not already in the basket). Empty otherwise. |
totals | TotalsV2 | Basket totals + always-present savings summary. |
recommendations | RecommendationV2[] | Near-miss / hint recommendations. |
appliedCoupons | AppliedCoupon[] | { code, couponTypeName, promotionIds[] }. |
invalidCoupons | InvalidCoupon[] | { code, reason }. |
budgetLimitedPromotions | BudgetLimitedPromotion[] | { promotionId, promotionName, reason } — promotions dropped because a linked budget is exhausted. |
missedPromotions | MissedPromotion[] | Present only when includeMissedPromotions: true (simulator diagnostic). |
nudges | NudgeV2[] | Present only when tenant flag enableProductionNudges is ON; otherwise empty. |
thresholdGaps | ThresholdGapV2[] | Present only when tenant flag enableProductionNudges is ON; otherwise empty. |
MetaV2:
| Field | Type | Notes |
|---|---|---|
header | RequestHeader | Echo, including the server-assigned transactionId + transactionCounter. |
evaluatedAt | Timestamp | When evaluation completed (ISO-8601). |
source | string | btp | local-store. |
instanceId | string | Instance identifier (e.g. BTP, STORE-001). |
isSimulation | boolean | true when produced by /v2/simulate. |
tenantId | string | Resolved tenant identifier. |
dataAge | Timestamp | Freshness of the underlying promotion data (last sync for local-store, last write for BTP). |
LineItemV2 (every monetary field is Money = { value, currency }):
| Field | Type | Notes |
|---|---|---|
lineReference | string(50) | Echoed from input (auto-assigned when omitted). |
articleNumber, ean, articleGroupId, manufacturerId | string | Echoed from input. |
quantity | Quantity | { value, unit }; unit defaults to "PCE". Negative for echoed return lines. |
unitPrice | Money | Per-unit price before discounts. |
lineTotal | Money | unitPrice × quantity (before discounts). |
lineDiscount | Money | Sum of all discounts[].discountAmount (positive). |
lineNet | Money | lineTotal − lineDiscount, floored at 0. |
discounts | LineDiscountV2[] | One entry per promotion that touched this line. Empty when none applied. |
isFreeItem | boolean | true when an already-in-basket line is granted free by a FREE_ITEM promotion (gift branch). |
freeItemPromotionId | UUID | Set only when isFreeItem is true. |
LineDiscountV2:
| Field | Type | Notes |
|---|---|---|
promotionId | UUID | Promotion that produced this discount. |
promotionName | string(255) | Display name. |
promotionType | string(20) | ARTICLE, RECEIPT, BUNDLE, … |
discountType | string(20) | PERCENTAGE | ABSOLUTE | UNIT_PRICE. |
discountValue | Decimal(15,2) | Configured value (percent or amount). |
discountAmount | Money | Actual monetary discount applied to this line. |
totalDiscount | Money | Canonical alias of discountAmount (identical value). New integrations should read totalDiscount; discountAmount is retained as a deprecated alias. |
couponCode | string(50) | Coupon that triggered this promotion, if any. |
triggeredByCoupon | boolean | Whether a coupon unlocked this promotion. |
TotalsV2:
| Field | Type | Notes |
|---|---|---|
subtotal | Money | Sum of every line lineTotal (before discounts). |
discount | Money | Sum of every line lineDiscount (positive). |
grandTotal | Money | subtotal − discount. |
saleSubtotal | Money | Present only when the basket contains return lines. |
returnSubtotal | Money | Present only when the basket contains return lines (negative). |
savingsSummary | SavingsSummaryV2 | Always present. |
SavingsSummaryV2: totalSavings (Money), savingsPercent (Decimal(5,2), ≤ 100), originalTotal (Money), finalTotal (Money), promotionBreakdown[] ({ promotionId, promotionName, totalDiscount: Money, affectedItems: string[] } — affectedItems are lineReference strings), itemSavings[] ({ articleNumber, originalPrice, finalPrice, savings } — all Money), loyaltyPointsEarned (integer).
GrantedItemV2 (FREE_ITEM inject branch only): grantReference (deterministic, GRANT-<promoShort>-<code>-<n>), articleNumber, ean, quantity (integer), referencePrice (Money), priceSource (BASKET_PRICE | REFERENCE_PRICE | MASTER_DATA | UNKNOWN_ZERO), giveAwayValue (Money), promotionId, promotionName, triggeredByCoupon.
RecommendationV2: kind (NEAR_MISS | HINT), code (stable failure-category code — see the Recommendation Hint Catalog), params (KeyValuePair[] for POS-side localisation), defaultMessage (English fallback), promotionId, promotionName. matchPercent is emitted dynamically and only on NEAR_MISS entries; HINT entries omit it entirely.
ThresholdGapV2: promotionId, promotionName, type (RECEIPT_AMOUNT | ITEM_QUANTITY | SCALED_RECEIPT), currentValue, threshold, gap (all Decimal(15,2)), potentialSaving (Money).
NudgeV2: message, suggestedAction, potentialSaving (Money), promotionId, promotionName, gapType.
Example
Request — basket with two lines; the customer is GOLD-tier:
POST /pos/v2/evaluate
{
"request": {
"header": { "transactionId": "TXN-2026-001" },
"posGroupId": "60000000-0000-4000-8000-000000000001",
"items": [
{ "lineReference": "L1", "articleNumber": "ART-1001", "ean": "4007817327098", "articleGroupId": "ELECTRONICS", "quantity": 2, "unitPrice": 89.99 },
{ "lineReference": "L2", "articleNumber": "CIG-1001", "quantity": 4, "unitPrice": 25.00 }
],
"customer": { "customerId": "CUST-4711", "loyaltyCardNo": "LC-98765", "loyalty": { "tier": "GOLD", "points": 1250.00 } },
"timestamp": "2026-06-07T14:30:00Z"
}
}
Response — a 10% PERCENTAGE ARTICLE promotion fires on ART-1001; nothing matches CIG-1001:
{
"minorVersion": 8,
"meta": { "header": { "transactionId": "TXN-2026-001", "transactionCounter": 1 }, "evaluatedAt": "2026-06-07T14:30:01.234Z", "source": "btp", "instanceId": "BTP", "isSimulation": false, "tenantId": "tenant-abc" },
"lineItems": [
{ "lineReference": "L1", "articleNumber": "ART-1001", "ean": "4007817327098", "articleGroupId": "ELECTRONICS", "manufacturerId": null,
"quantity": { "value": 2.000, "unit": "PCE" },
"unitPrice": { "value": 89.99, "currency": "EUR" }, "lineTotal": { "value": 179.98, "currency": "EUR" },
"lineDiscount": { "value": 18.00, "currency": "EUR" }, "lineNet": { "value": 161.98, "currency": "EUR" },
"discounts": [ { "promotionId": "10000000-0000-4000-8000-000000000001", "promotionName": "Electronics 10% Off", "promotionType": "ARTICLE", "discountType": "PERCENTAGE", "discountValue": 10, "discountAmount": { "value": 18.00, "currency": "EUR" }, "totalDiscount": { "value": 18.00, "currency": "EUR" }, "couponCode": null, "triggeredByCoupon": false } ],
"isFreeItem": false, "freeItemPromotionId": null },
{ "lineReference": "L2", "articleNumber": "CIG-1001", "ean": null, "articleGroupId": null, "manufacturerId": null,
"quantity": { "value": 4.000, "unit": "PCE" },
"unitPrice": { "value": 25.00, "currency": "EUR" }, "lineTotal": { "value": 100.00, "currency": "EUR" },
"lineDiscount": { "value": 0.00, "currency": "EUR" }, "lineNet": { "value": 100.00, "currency": "EUR" },
"discounts": [], "isFreeItem": false, "freeItemPromotionId": null }
],
"grantedItems": [],
"totals": {
"subtotal": { "value": 279.98, "currency": "EUR" }, "discount": { "value": 18.00, "currency": "EUR" }, "grandTotal": { "value": 261.98, "currency": "EUR" },
"savingsSummary": { "totalSavings": { "value": 18.00, "currency": "EUR" }, "savingsPercent": 6.43, "originalTotal": { "value": 279.98, "currency": "EUR" }, "finalTotal": { "value": 261.98, "currency": "EUR" },
"promotionBreakdown": [ { "promotionId": "10000000-0000-4000-8000-000000000001", "promotionName": "Electronics 10% Off", "totalDiscount": { "value": 18.00, "currency": "EUR" }, "affectedItems": ["L1"] } ],
"itemSavings": [ { "articleNumber": "ART-1001", "originalPrice": { "value": 179.98, "currency": "EUR" }, "finalPrice": { "value": 161.98, "currency": "EUR" }, "savings": { "value": 18.00, "currency": "EUR" } } ],
"loyaltyPointsEarned": 0 }
},
"recommendations": [], "appliedCoupons": [], "invalidCoupons": [], "budgetLimitedPromotions": [], "nudges": [], "thresholdGaps": []
}
The coupons field is an array of objects ([{ "code": "…" }]), not bare strings. See Coupons on the basket (wire-format) for the full shape and multi-coupon behaviour.
For the full catalogue of promotion scenarios and how each action type surfaces in this response (line discounts vs. grantedItems[] vs. loyalty points vs. receipt-only totalDiscount), see the Promotion Scenarios & Action Types guide.
POST /pos/v2/simulate
Identical evaluation logic and identical EvaluateResponseV2 shape as /v2/evaluate, but read-only: it does not redeem coupons, consume budgets, or write audit logs. The response carries meta.isSimulation: true. The simulator-only flags includeInactive and includeMissedPromotions are honoured here.
Transaction Confirmation
POST /pos/v2/confirm
Confirms a POS transaction. Triggers the side effects: coupon redemption, budget consumption, calculation audit logging, and post-purchase coupon generation.
On BTP, primary side effects (budget consumption, calculation log) run synchronously; secondary side effects (coupon redemption, loyalty, receipt, post-purchase coupons, webhooks) run asynchronously via the side-effects worker — poll their status with the side-effects endpoint below.
Request — ConfirmRequest
| Field | Type | Required | Notes |
|---|---|---|---|
header | RequestHeader | yes | header.transactionId and header.transactionCounter are required to identify which iteration to commit. |
transactionId | string(50) | yes | Transaction identifier. |
posGroupId / posGroupCode | string | optional | POS group (UUID or code). |
appliedPromotions | ConfirmPromotion[] | yes | Promotions to finalise. |
customerId | string(50) | optional | For post-purchase coupon assignment. |
timestamp | ISO-8601 | optional | Transaction time. |
items | ConfirmBasketItem[] | optional | Structured basket lines with final pricing, for audit logging. |
ConfirmPromotion: promotionId (required), couponCode, totalDiscount (Decimal — scalar, backwards-compatible), discountAmount (Money — additive, mirrors LineDiscountV2.discountAmount so an evaluate response can be echoed verbatim). When both are present, discountAmount.value takes precedence for budget consumption.
{
"request": {
"header": { "transactionId": "TXN-2026-001", "transactionCounter": 1 },
"transactionId": "TXN-2026-001",
"posGroupId": "60000000-0000-4000-8000-000000000001",
"appliedPromotions": [
{ "promotionId": "10000000-0000-4000-8000-000000000001", "couponCode": null, "discountAmount": { "value": 18.00, "currency": "EUR" } }
],
"customerId": "CUST-4711",
"timestamp": "2026-06-07T14:30:45Z"
}
}
Response — ConfirmResponse: { transactionId, confirmed, message }.
GET /pos/v2/transactions/{transactionId}/{transactionCounter}/side-effects
Polls the async side-effects job for a confirmed transaction. Cached server-side for ~1 second to dampen tight polling loops. Returns { minorVersion, transactionId, transactionCounter, status, enqueuedAt, startedAt, completedAt, attempts, couponsRedeemed, budgetsConsumed, loyaltyPointsEarned, postPurchaseCoupons[], reason }, where each postPurchaseCoupons[] entry is { code, couponTypeId, customerId, issuedAt }.
Coupon Operations
All coupon endpoints accept a POST with a JSON body and return JSON. The coupon lifecycle is validate → reserve → redeem (with cancel to release a reservation); digital coupons add activate; CRM/marketing systems use issue.
Coupons on the basket (wire-format)
The coupons field on the POST /pos/v2/evaluate (and /pos/v2/simulate)
request body is an array of objects, each with a code — not an array of
bare strings. Sending a string array is rejected with 400 VALIDATION_FAILED
(target: coupons).
Correct — object array:
{
"request": {
"header": { "transactionId": "TXN-2026-001" },
"posGroupId": "60000000-0000-4000-8000-000000000001",
"items": [
{ "lineReference": "L1", "articleNumber": "ART-1001", "quantity": 1, "unitPrice": 12.50 }
],
"coupons": [
{ "code": "WELCOME15" },
{ "code": "SUMMER25" }
]
}
}
Incorrect — string array (rejected):
{
"coupons": ["WELCOME15", "SUMMER25"]
}
CouponInput schema:
| Field | Type | Required | Description |
|---|---|---|---|
code | string | yes | The coupon code to apply. Present on every coupon object. |
Multiple coupons are evaluated in array order: each code resolves to its
promotion(s), which stack by default unless an ExclusivityLevel or
ExclusionGroups rule blocks the combination; the first-applied promotion wins
ties.
At POST /pos/v2/confirm there is no top-level coupons field — the coupon
that triggered each finalised promotion is carried per-promotion as
appliedPromotions[].couponCode (see Transaction Confirmation).
For the validate → reserve → redeem flow, see the coupon endpoints below and the
coupon lifecycle guide.
POST /pos/coupons/validate
Validates a coupon code. Request { code }. Response { valid, code, message, promotionId, validUntil }.
POST /pos/coupons/reserve
Reserves a coupon for a transaction. Request { code, transactionId }. Response { reservationId, reserved, expiresAt, message }. Redeem before expiresAt.
POST /pos/coupons/redeem
Redeems a previously reserved coupon. Request { reservationId, transactionId }. Response { redeemed, message }.
POST /pos/coupons/cancel
Cancels a reservation, making the coupon available again. Request { reservationId }. Response { cancelled, message }.
POST /pos/coupons/activate
Activates a digital coupon (CREATED → ACTIVE). Used by mobile apps when a customer activates a personalized coupon. Request { code, customerId }. Response { activated, code, message, validUntil }.
POST /pos/coupons/issue
Issues personalized coupons for customers (single or batch). Used by CRM / marketing automation. Request { couponTypeId | couponTypeName, customerId | customerIds[], reason, metadata }. Response { issuedCount, failedCount, issuedCoupons[], failures[], message }.
Rate limited to 1000 issuances per minute per tenant.
Article Import
POST /pos/articles/import
Synchronous article import with upsert semantics (create if new, update if exists based on articleNumber). Each article is processed independently — one failure does not abort the batch. The request body wraps the array in articles (the action's named parameter).
Request:
{
"articles": [
{
"articleNumber": "ART-10006",
"name": "Mineral Water 0.5L",
"ean": "4001954600106",
"manufacturer": "Spring Corp",
"category": "Beverages"
}
]
}
Response (ArticleImportResponse):
{
"imported": 1,
"updated": 0,
"failed": 0,
"results": [
{ "articleNumber": "ART-10006", "status": "created", "id": "uuid" }
],
"errors": []
}
Each results[] entry is { articleNumber, status (created | updated | failed), id, error }.
POST /pos/articles/import-batch
Asynchronous batch import with job tracking. Creates a persistent import job and processes articles in the background. Body: { name, articles[] }. Returns immediately with { jobId, status, message }.
Monitor job progress via GET /admin/ArticleImportJobs({jobId}) (Admin Service) or GET /api/v1/ArticleImportJobs({jobId}) (PublicAPI).
ArticleImportJobs tracks POS-originated batch imports only. For imports submitted via POST /api/v1/Articles:bulk-import (PublicAPI bulk import), use ImportJobs + ImportJobErrors instead. See Article Import API for the full comparison.
Health
GET /pos/heartbeat
Returns connectivity and health diagnostics: { status, version, mode, btpConnectivity, lastSync, pendingTransactions, promotionsLoaded, uptime }. On local-store instances it reflects circuit-breaker state and the pending transaction queue; on BTP it returns a simplified status.
Authorization
Requires the Admin or POSReader role.
| User (Development) | Password | Role |
|---|---|---|
admin | admin | Admin |
pos-reader | pos | POSReader |
Integration Patterns
For detailed integration guidance including authentication, polling strategies, error handling, and data synchronization, see the Integrator Guide. For a per-scenario walkthrough of every promotion/action type and where it appears in the response, see Promotion Scenarios & Action Types.