Zum Hauptinhalt springen

Public API (/api/v1)

The Public API is the stable external contract for integrating with the Digital Retail Engine. It is a projection over the persistent data model (dre.srv) — deliberately decoupled from the Admin Service so that internal UI churn never leaks into the integrator contract.

PropertyValue
Base Path/api/v1
ProtocolOData V4 and REST (dual-mounted on the same path)
AuthOAuth 2.0 client credentials (XSUAA JWT)
Read scopePublicApi.Read
Write scopePublicApi.Write
VersioningMajor in URL (/api/v1); minor as minorVersion integer in every response body
info
Path is /api/v1 — there is no /odata/v4/ prefix

CAP services normally default to /odata/v4/<service>, but the Public API mounts directly at /api/v1 via @path: '/api/v1'. OData metadata is at GET /api/v1/$metadata. The same path serves both the OData V4 and the REST flavour.

Versioning

Every successful (2xx) response carries a minorVersion integer in its body. The major version lives in the URL (/api/v1) and only changes for breaking changes. Each additive change (new collection, new optional field, new action) bumps the minor version.

hinweis
minorVersion is 13 as of this release

The current minor version is 13. It has been incremented additively across releases:

VersionChange
7Campaigns writable (addPromotions / removePromotion / exportICS); AuditLogs read-only union; PromotionTemplates family; PromotionPilotConfigs CRUD
8Promotions fields isExperiment / controlGroupId / experimentGroupId (A/B testing)
9Tier-1 webhook event expansion (PROMOTION_EXPIRED, COUPON_EXPIRED, ArticleImportJobs.*)
10Tier-2 webhook events (PROMOTION_SUBMITTED, PROMOTION_ARCHIVED, CAMPAIGN_*, BUDGET_EXHAUSTED, COUPON_BATCH_*)
11Tier-3 webhook events (LOYALTY_POINTS_AWARDED, TRANSACTION_RETURNED, PROMOTION_UPDATED, WEBHOOK_DELIVERY_DEAD_LETTER); payload-envelope enrichment
12CouponCodes post-purchase attribution fields (issuedAt, issuingPromotionId, issuingTransactionId, issuanceChannel) exposed on /api/v1
13Campaigns read-only locked field exposed on /api/v1; PATCH { "locked": … } returns 405 (Campaign Lock feature)

All changes are additive — existing clients pinned to an earlier minor version continue to work unchanged.

Authentication & scopes

All requests require an OAuth 2.0 client-credentials JWT issued by SAP XSUAA. Pass it as Authorization: Bearer <jwt>.

Two scopes gate the API; the verb determines which one is required:

ScopeGrantsRole template
PublicApi.ReadGET on every exposed collectionPublicApiReader
PublicApi.WritePOST / PATCH / DELETE and all write actions on writable collectionsPublicApiWriter

The PublicApiWriter role template includes read, so a writer can also read. Scope enforcement is per-method: a GET needs PublicApi.Read; any POST/PATCH/DELETE (and unbound write actions such as createPromotionFromTemplate) needs PublicApi.Write. A request that lacks the required scope is rejected 403.

Tenant isolation applies to every writable collection

Every writable /api/v1 collection is tenant-scoped: a caller only ever sees and writes rows belonging to its own tenant (resolved from the JWT). Cross-tenant access does not error with 403 — the row is simply invisible, so a GET of another tenant's row returns 404, and a DELETE/PATCH of it returns 404 (it does not exist in the caller's scope). See Tenant isolation below.

Collection catalogue

The API exposes the data model in four bands. The Mode column is the contract: RW = full CRUD, RW* = create/read/scoped-update only, RO = read-only (writes are rejected — see read-only enforcement).

Writable business entities (RW)

Promotions, DiscountActions, BundleComponents, Conditions, ConditionValueListItems, ConditionArticleListItems, Articles, ArticleGroups, PosGroups, PriorityGroups, PromotionPosGroups, CouponCodeConfigurations, CouponCodeBatches, CouponRefillSchedules, CouponTypes, CouponTypePromotions, Budgets, BudgetPromotions, BudgetNotifications, Stakeholders, BudgetStakeholders, StakeholderPromotions, RechargeContributions, PriorityGroupPromotions, MutualExclusionGroups, WebhookSubscriptions.

New this release: Campaigns + CampaignPosGroups, PromotionTemplates + 11 Template* children, PromotionPilotConfigs + PilotPosGroups / ControlPosGroups.

Semi read-only entities (RW*)

EntityModeNotes
CouponCodesRW*GET + status-only PATCH. Direct POST/DELETE405. External code pools are imported via the bulkImport action (rows set source = EXTERNAL). The secret code value is never serialised on reads.
ImportJobsRW*Server-managed status; client may PATCH callbackUrl only and DELETE only terminal jobs (409 while running).

Read-only entities & views (RO)

AuditLogs (new — see below), RedemptionLogs, BudgetConsumptions, CalculationLogs, CalculationLogItems, CalculationLogPromotions, NotificationLogs, ImportJobErrors, LocalDeployments, SyncHistory, PromotionsCalendar, ArticleImportJobs (deprecated), ArticleImportJobItems (deprecated).

Code lists (RO)

Global value-help tables, grouped under the OpenAPI tag CodeLists: PromotionStatuses, PromotionTypes, ExclusivityLevels, DiscountTypes, ApplicationQuantityModes, CalculationModes, RoundingModes, SelectionTypes, ArticleSources, GroupTypes, LogicalConnectors, ComparisonOperators, ConditionTypes, ConditionValueCategories, ActionTypes, DistributionModes, ExclusionTypes, ResolutionStrategies, BudgetStatuses, CouponCodeTypes, CouponStatuses, CouponCodeStatuses, StakeholderTypes, CalculationLogStatuses, LoyaltyActionTypes, LoyaltyTargetScopes, CampaignStatuses. Code lists are tenant-agnostic (shared across all tenants).

Read/Write matrix

VerbRead-only (RO)Semi (RW*)Writable (RW)Scope
GET collection / entityPublicApi.Read
POST (create)405405 (use action)PublicApi.Write
PATCH (update)405⚠️ scoped fields onlyPublicApi.Write
DELETE405⚠️ terminal only (ImportJobs)✅ (soft-delete on some)PublicApi.Write
bulkImport actionn/aCouponCodes only✅ (selected entities)PublicApi.Write

Read-only enforcement

A write against an RO collection is rejected:

  • REST clients receive 405 Method Not Allowed with an Allow: GET header.
  • OData clients receive 403.

Bulk import

Writable business entities expose a bulkImport bound action for mass create/upsert (Articles, Promotions, Budgets, Stakeholders, CouponTypes, CouponCodeConfigurations, Conditions, ArticleGroups, PosGroups, PriorityGroups, MutualExclusionGroups, CouponCodes). All bulk imports are tracked through the canonical ImportJobs entity. See the dedicated Article Import API page for the request body schema, idempotency, per-tenant limits, and polling.

Child entities do not bulk-import independently

The async import worker only persists Promotions and the other top-level writable entities. Composition children that are reached only through a parent (e.g. BundleComponents, ConditionValueListItems, the Template* children, PilotPosGroups / ControlPosGroups) are imported as part of their parent's payload via deep-insert — there is no functional standalone child bulkImport.


Campaigns (RW)

Campaigns group related promotions (e.g. "Easter 2026", "BBQ Summer"). They were exposed read-only in an earlier release; this release makes them writable on /api/v1.

Read → write transition (additive)

The previously read-only Campaigns collection now accepts POST / PATCH / DELETE and three new write actions. This is purely additive — existing read integrations are unaffected — and is covered by the shared minorVersion bump to 7.

GET    /api/v1/Campaigns
POST /api/v1/Campaigns
GET /api/v1/Campaigns(<ID>)
PATCH /api/v1/Campaigns(<ID>)
DELETE /api/v1/Campaigns(<ID>)
GET /api/v1/CampaignPosGroups

Fields

FieldTypeNotes
IDUUIDKey (server-generated on create)
codeString(50)Required. Short campaign code for cross-system references
nameString(255)Required. Display name
descriptionString(1000)Optional
startAt / endAtDateTimeCampaign window
statusAssociation → CampaignStatusesDefaults to DRAFT
ownerStakeholderAssociation → StakeholdersResponsible stakeholder
primaryContactAssociation → StakeholdersAccountable person
notesString(2000)Free-form
mergePromotionPosGroupsBooleanfalse (default) = campaign POS groups win; true = union with attached promotions' POS groups
lockedBooleanRead-only. false by default. When true, the campaign and all its linked promotions are frozen against authoring changes. Toggle via the Admin Service lock / unlock bound actions (not available on /api/v1). Runtime evaluation at /pos/v2/evaluate is unaffected by the locked state.
tenantIdString(36)Owning tenant (auto-stamped from JWT; see tenant isolation)
minorVersionIntegerStamped on every 2xx response

posGroups (CampaignPosGroups) is a composition — deep-insert it with the campaign or address it via GET/POST /api/v1/Campaigns(<ID>)/posGroups. promotions is a read-only back-navigation (promotions are attached to a campaign via the actions below, not by writing the navigation).

Campaign lock

The locked field is read-only on the Public API. A PATCH /api/v1/Campaigns({ID}) request that includes a locked property is rejected 405 regardless of the value supplied. To lock or unlock a campaign, use the lock / unlock bound actions on the Admin Service (AdminService.lock / AdminService.unlock) — these actions are not exposed on /api/v1.

Runtime evaluation is not affected

A locked campaign's promotions continue to fire at /pos/v2/evaluate and /pos/v2/confirm. The lock is an authoring gate only — it prevents further edits to the campaign and its linked promotions through the admin/authoring path, but active promotions already in the engine cache are evaluated as normal.

Actions

ActionHTTPScopeReturns
addPromotions(promotionIds: [UUID])POSTPublicApi.WriteThe updated Campaigns row
removePromotion(promotionId: UUID)POSTPublicApi.WriteThe updated Campaigns row
exportICS()GET (function)PublicApi.ReadRFC 5545 iCalendar string

addPromotions

Links one or more promotions to the campaign by setting their campaign_ID. Idempotent — promotions already linked to this campaign are silently skipped, so a retry is safe.

POST /api/v1/Campaigns(<ID>)/addPromotions
Authorization: Bearer <jwt>
Content-Type: application/json

{ "promotionIds": ["a1b2…", "c3d4…"] }
  • Returns the updated Campaigns row (no follow-up GET needed).
  • Promotion IDs that belong to another tenant are rejected 400 (cross-tenant link refused) — the request is not partially applied to foreign IDs.
  • 400 if promotionIds is empty or missing.
  • A campaign ID that is not owned by the caller returns 404 (it does not exist in the caller's scope).

OData address form. Under OData the action is POST /api/v1/Campaigns(<ID>)/PublicAPI.addPromotions. The REST alias drops the namespace prefix.

removePromotion

Unlinks a single promotion (clears its campaign_ID). Idempotent — unlinking a promotion that is not linked is a no-op. Returns the updated Campaigns row.

POST /api/v1/Campaigns(<ID>)/removePromotion
{ "promotionId": "a1b2…" }

exportICS

Returns the campaign as an RFC 5545 (iCalendar) VEVENT string (one VCALENDAR with one VEVENT). Modelled as a side-effect-free OData function (HTTP GET, cacheable), so it needs only the read scope.

GET /api/v1/Campaigns(<ID>)/exportICS
Authorization: Bearer <jwt>

The iCalendar text is returned in the function response body. (Integrators receive the raw string — there is no Fiori file-download media-type coupling on /api/v1.)


CouponCodes (RW*)

Coupon codes issued against coupon types. GET and scoped PATCH (status fields) are allowed; direct POST/DELETE are rejected (405). External code pools are imported via the bulkImport action.

GET  /api/v1/CouponCodes
GET /api/v1/CouponCodes(<ID>)
PATCH /api/v1/CouponCodes(<ID>)
POST /api/v1/CouponCodes/bulkImport

Fields

FieldTypeNotes
IDUUIDKey (server-generated)
couponTypeAssociation → CouponTypesThe coupon type this code belongs to
statusAssociation → CouponCodeStatusesLifecycle status: ACTIVE, REDEEMED, CANCELLED, EXPIRED
customerIdString(50)Optional customer assignment for personalised codes. null for anonymous bearer codes
reservationRefUUIDUnique UUID generated per reservation (changes on each reserve)
sourceAssociation → ArticleSourcesOrigin source; set to EXTERNAL for bulk-imported codes
issuedAtTimestampWhen this code was handed to a customer. null = still in the pool (not yet handed out). Basis for time-to-redeem analytics
issuingPromotionIdString(36)ID of the promotion that issued this code at checkout (post-purchase issuance). null for batch-generated pool codes, manual admin-issued codes, and external imports
issuingTransactionIdString(50)POS basket transaction ID that triggered post-purchase issuance. Used as idempotency key: one code is issued per (issuingTransactionId, issuingPromotionId, couponType) combination. null for non-post-purchase codes
issuanceChannelString (enum)Origin channel for this code. See Issuance channels below. null for pool codes created before this field was introduced
tenantIdString(36)Owning tenant (auto-stamped)
minorVersionIntegerStamped on every 2xx response
hinweis
code is never serialised

The secret code value is suppressed on all public API responses (@cds.api.ignore). Use the Admin Service for legitimate code-lookup use-cases (customer support, issue audits).

Issuance channels

The issuanceChannel field identifies how a code reached a customer. The value is null for pool codes that pre-date the field's introduction.

ValueMeaning
POST_PURCHASEIssued at checkout by the promotion engine as a post-purchase reward (side-effects worker, async)
BATCHPre-generated by the coupon auto-refill scheduler (CouponRefillSchedules)
MANUALIssued by an admin via the generateCodes action on a CouponTypes record
EXTERNALImported from an external system via bulkImport

Pool vs. issued codes

  • issuedAt IS NULL — the code is in the pool: pre-generated but not yet handed to a customer. Pool codes have status = ACTIVE.
  • issuedAt IS NOT NULL — the code has been handed out. For post-purchase codes, issuingPromotionId and issuingTransactionId are also set.

Filtering on issuingPromotionId ne null returns only codes issued via a promotion at checkout — useful for post-purchase coupon analytics.


AuditLogs (RO)

A read-only collection that surfaces both the generic change-audit rows and the budget field-change rows behind one filterable, sortable view. It mirrors the union the Admin Service exposes, minus the role gate (which PublicApi tokens never carry).

GET /api/v1/AuditLogs
GET /api/v1/AuditLogs(<ID>)
GET /api/v1/AuditLogs?$filter=entityType eq 'Budgets'&$orderby=timestamp desc
FieldTypeNotes
IDUUIDKey
timestampTimestampWhen the change happened
actorStringWho made the change
actionStringMachine-readable action code (stable value)
entityTypeStringe.g. Promotions, Budgets
entityIdStringAffected row ID
entityNameStringAffected row display name (falls back to entityId)
noteStringFree-form note; for budget rows: field: old -> new
tenantIdStringOwning tenant
  • Read-only. Any POST/PATCH/DELETE405 (REST) / 403 (OData).
  • Tenant-filtered. Both union branches carry tenantId, so a caller sees only its own tenant's audit rows.
  • The raw machine-readable action code is returned (there is no $expand to an action-text table on /api/v1).

PromotionTemplates (RW)

Reusable promotion blueprints. A template carries a flat set of conditions, discount actions, and loyalty actions — the same shape as a Promotion — and can be instantiated into a new draft Promotion via createPromotionFromTemplate.

GET    /api/v1/PromotionTemplates
POST /api/v1/PromotionTemplates
GET /api/v1/PromotionTemplates(<ID>)
PATCH /api/v1/PromotionTemplates(<ID>)
DELETE /api/v1/PromotionTemplates(<ID>)

Parent fields

FieldTypeNotes
IDUUIDKey
codeString(50)Short template code
nameString(255)Required. Display name
descriptionString(1000)Optional
useCaseAssociation → UseCasesRetail scenario category
typeAssociation → PromotionTypesPromotion type
priorityGroupAssociation → PriorityGroupsOptional
tenantIdString(36)Owning tenant (auto-stamped)
minorVersionIntegerStamped on every 2xx response

Composition children

Each child has its own addressable EntitySet and can be deep-inserted with the parent. The 11 children:

Child collectionReached via
TemplateConditionsPromotionTemplates
TemplateConditionValueListItemsTemplateConditions
TemplateConditionArticleListItemsTemplateConditions
TemplateDiscountActionsPromotionTemplates
TemplateDiscountActionScaledTiersTemplateDiscountActions
TemplateDiscountActionQuantityTiersTemplateDiscountActions
TemplateDiscountActionBundleComponentsTemplateDiscountActions
TemplateDiscountActionArticleListItemsTemplateDiscountActions
TemplateDiscountActionExclusionsTemplateDiscountActions
TemplateDiscountActionExclusionArticleListItemsTemplateDiscountActionExclusions
TemplateLoyaltyActionsPromotionTemplates

A deep POST /api/v1/PromotionTemplates with nested conditions / discountActions / loyaltyActions creates the whole tree in one call. None of the children carry their own tenantId — they inherit the parent's tenant and are tenant-filtered through it (see tenant isolation).

createPromotionFromTemplate

Unbound action that materialises a new draft Promotion (with its conditions and discount/loyalty actions) from an existing template.

POST /api/v1/createPromotionFromTemplate
Authorization: Bearer <jwt>
Content-Type: application/json

{ "templateId": "a1b2c3d4-…" }
AspectBehaviour
ScopePublicApi.Write (rejected 403 without it)
ReturnsThe new Promotion's ID (a UUID string)
OwnershiptemplateId must be owned by the caller's tenant; a foreign or unknown template returns 404 (indistinguishable — no existence oracle)
400templateId missing
Non-idempotent — do not blind-retry

Each call creates a new Promotion with a new UUID (there is no client-supplied key). A blind retry on a network timeout will create a duplicate Promotion. Do not auto-retry; instead use the returned UUID to confirm the outcome (GET /api/v1/Promotions(<returnedId>)) before deciding whether to call again.


PromotionPilotConfigs (RW)

A pilot/control POS-group split plus a pilot window for a single Promotion (A/B pilot configuration). Full CRUD on /api/v1.

GET    /api/v1/PromotionPilotConfigs
POST /api/v1/PromotionPilotConfigs
GET /api/v1/PromotionPilotConfigs(<ID>)
PATCH /api/v1/PromotionPilotConfigs(<ID>)
DELETE /api/v1/PromotionPilotConfigs(<ID>)

Parent fields

FieldTypeNotes
IDUUIDKey
promotionAssociation → PromotionsRequired. One config per promotion
pilotStartAt / pilotEndAtDateTimeRequired. The pilot window
autoPromoteBooleanWhen true, the auto-promote job flips PILOT → ACTIVE if uplift ≥ threshold
autoPromoteThresholdDecimal(5,2)Minimum uplift % for auto-promote
pilotShareDecimal(3,2)Fraction of pilot-group POS that receive the promotion. 1.00 = all (default). Range [0.00, 1.00]
controlGroupSizeIntegerAbsolute cap on control sample size. 0 = use all control POS (default). Range [0, 999999]
tenantIdString(36)Owning tenant (auto-stamped)
minorVersionIntegerStamped on every 2xx response

Composition children

Child collectionArmReached via
PilotPosGroupsPilotPromotionPilotConfigs
ControlPosGroupsControlPromotionPilotConfigs

Address them directly (GET/POST /api/v1/PilotPosGroups, …/ControlPosGroups) or deep-insert with the parent. Neither child carries its own tenantId; both are tenant-filtered through the parent config.

One config per promotion

A unique constraint enforces a single pilot config per promotion. A second POST for an already-configured promotion returns 409. The uniqueness check runs before the tenant filter, so a duplicate against an existing promotion gets a clean 409 (not a silent cross-tenant mask).

Pilot lifecycle actions are Admin-only

The pilot lifecycle actions (startPilot, endPilot, promoteToActive) live on the Promotion, not on the config, and remain Admin-gated. They are not exposed on /api/v1. The Public API covers pilot-config CRUD only.


Tenant isolation

Every writable /api/v1 collection is scoped to the caller's tenant (resolved from the JWT). The runtime applies a tenant filter on every read and validates the tenant on every write. The rules:

  • Reads are filtered to the caller's tenant. A GET of another tenant's row returns 404 (it is invisible, not forbidden).
  • Writes are validated: a POST/PATCH/DELETE that targets another tenant's row returns 404; a body that claims a foreign tenantId is rejected 403. The caller cannot create or move a row into another tenant.
  • Composition children without their own tenantId (e.g. CampaignPosGroups, the Template* children, PilotPosGroups / ControlPosGroups) are filtered through their parent — a direct GET on a child cannot bypass the parent to leak cross-tenant rows.
  • Bound actions (addPromotions, removePromotion, exportICS, …) on a tenant-scoped entity resolve through the same ownership gate: a foreign or non-existent target key returns 404.
  • Code lists are tenant-agnostic — they are shared value-help tables, the same for every tenant.

This per-collection isolation was generalised across all writable collections in this release; the original pattern (Budgets) now applies uniformly.

Error format

Errors follow RFC 7807 (problem+json). Common statuses:

StatusMeaning
400Validation error (bad body, empty required array, foreign ID in a link action)
403Missing required scope, or write body claims a foreign tenantId
404Row not found or not owned by the caller's tenant (no existence oracle)
405Write attempted on a read-only collection (REST; Allow: GET)
409Uniqueness conflict (e.g. second pilot config for a promotion) or DELETE of a non-terminal ImportJobs row
413Bulk-import payload exceeds the per-tenant row cap
429Too many concurrent bulk-import jobs for the tenant (Retry-After)

OpenAPI spec

The machine-readable contract is published alongside the docs:

The published spec is the source of truth for field-level shapes; this page documents behaviour, scopes, and the action contracts.

See Also