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.
| Property | Value |
|---|---|
| Base Path | /api/v1 |
| Protocol | OData V4 and REST (dual-mounted on the same path) |
| Auth | OAuth 2.0 client credentials (XSUAA JWT) |
| Read scope | PublicApi.Read |
| Write scope | PublicApi.Write |
| Versioning | Major in URL (/api/v1); minor as minorVersion integer in every response body |
/api/v1 — there is no /odata/v4/ prefixCAP 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.
minorVersion is 13 as of this releaseThe current minor version is 13. It has been incremented additively across releases:
| Version | Change |
|---|---|
| 7 | Campaigns writable (addPromotions / removePromotion / exportICS); AuditLogs read-only union; PromotionTemplates family; PromotionPilotConfigs CRUD |
| 8 | Promotions fields isExperiment / controlGroupId / experimentGroupId (A/B testing) |
| 9 | Tier-1 webhook event expansion (PROMOTION_EXPIRED, COUPON_EXPIRED, ArticleImportJobs.*) |
| 10 | Tier-2 webhook events (PROMOTION_SUBMITTED, PROMOTION_ARCHIVED, CAMPAIGN_*, BUDGET_EXHAUSTED, COUPON_BATCH_*) |
| 11 | Tier-3 webhook events (LOYALTY_POINTS_AWARDED, TRANSACTION_RETURNED, PROMOTION_UPDATED, WEBHOOK_DELIVERY_DEAD_LETTER); payload-envelope enrichment |
| 12 | CouponCodes post-purchase attribution fields (issuedAt, issuingPromotionId, issuingTransactionId, issuanceChannel) exposed on /api/v1 |
| 13 | Campaigns 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:
| Scope | Grants | Role template |
|---|---|---|
PublicApi.Read | GET on every exposed collection | PublicApiReader |
PublicApi.Write | POST / PATCH / DELETE and all write actions on writable collections | PublicApiWriter |
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.
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*)
| Entity | Mode | Notes |
|---|---|---|
CouponCodes | RW* | GET + status-only PATCH. Direct POST/DELETE → 405. External code pools are imported via the bulkImport action (rows set source = EXTERNAL). The secret code value is never serialised on reads. |
ImportJobs | RW* | 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
| Verb | Read-only (RO) | Semi (RW*) | Writable (RW) | Scope |
|---|---|---|---|---|
GET collection / entity | ✅ | ✅ | ✅ | PublicApi.Read |
POST (create) | ❌ 405 | ❌ 405 (use action) | ✅ | PublicApi.Write |
PATCH (update) | ❌ 405 | ⚠️ scoped fields only | ✅ | PublicApi.Write |
DELETE | ❌ 405 | ⚠️ terminal only (ImportJobs) | ✅ (soft-delete on some) | PublicApi.Write |
bulkImport action | n/a | CouponCodes only | ✅ (selected entities) | PublicApi.Write |
Read-only enforcement
A write against an RO collection is rejected:
- REST clients receive
405 Method Not Allowedwith anAllow: GETheader. - 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.
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.
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
| Field | Type | Notes |
|---|---|---|
ID | UUID | Key (server-generated on create) |
code | String(50) | Required. Short campaign code for cross-system references |
name | String(255) | Required. Display name |
description | String(1000) | Optional |
startAt / endAt | DateTime | Campaign window |
status | Association → CampaignStatuses | Defaults to DRAFT |
ownerStakeholder | Association → Stakeholders | Responsible stakeholder |
primaryContact | Association → Stakeholders | Accountable person |
notes | String(2000) | Free-form |
mergePromotionPosGroups | Boolean | false (default) = campaign POS groups win; true = union with attached promotions' POS groups |
locked | Boolean | Read-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. |
tenantId | String(36) | Owning tenant (auto-stamped from JWT; see tenant isolation) |
minorVersion | Integer | Stamped 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.
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
| Action | HTTP | Scope | Returns |
|---|---|---|---|
addPromotions(promotionIds: [UUID]) | POST | PublicApi.Write | The updated Campaigns row |
removePromotion(promotionId: UUID) | POST | PublicApi.Write | The updated Campaigns row |
exportICS() | GET (function) | PublicApi.Read | RFC 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
Campaignsrow (no follow-upGETneeded). - Promotion IDs that belong to another tenant are rejected
400(cross-tenant link refused) — the request is not partially applied to foreign IDs. 400ifpromotionIdsis 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
| Field | Type | Notes |
|---|---|---|
ID | UUID | Key (server-generated) |
couponType | Association → CouponTypes | The coupon type this code belongs to |
status | Association → CouponCodeStatuses | Lifecycle status: ACTIVE, REDEEMED, CANCELLED, EXPIRED |
customerId | String(50) | Optional customer assignment for personalised codes. null for anonymous bearer codes |
reservationRef | UUID | Unique UUID generated per reservation (changes on each reserve) |
source | Association → ArticleSources | Origin source; set to EXTERNAL for bulk-imported codes |
issuedAt | Timestamp | When this code was handed to a customer. null = still in the pool (not yet handed out). Basis for time-to-redeem analytics |
issuingPromotionId | String(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 |
issuingTransactionId | String(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 |
issuanceChannel | String (enum) | Origin channel for this code. See Issuance channels below. null for pool codes created before this field was introduced |
tenantId | String(36) | Owning tenant (auto-stamped) |
minorVersion | Integer | Stamped on every 2xx response |
code is never serialisedThe 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.
| Value | Meaning |
|---|---|
POST_PURCHASE | Issued at checkout by the promotion engine as a post-purchase reward (side-effects worker, async) |
BATCH | Pre-generated by the coupon auto-refill scheduler (CouponRefillSchedules) |
MANUAL | Issued by an admin via the generateCodes action on a CouponTypes record |
EXTERNAL | Imported 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 havestatus = ACTIVE.issuedAt IS NOT NULL— the code has been handed out. For post-purchase codes,issuingPromotionIdandissuingTransactionIdare 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
| Field | Type | Notes |
|---|---|---|
ID | UUID | Key |
timestamp | Timestamp | When the change happened |
actor | String | Who made the change |
action | String | Machine-readable action code (stable value) |
entityType | String | e.g. Promotions, Budgets |
entityId | String | Affected row ID |
entityName | String | Affected row display name (falls back to entityId) |
note | String | Free-form note; for budget rows: field: old -> new |
tenantId | String | Owning tenant |
- Read-only. Any
POST/PATCH/DELETE→405(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
actioncode is returned (there is no$expandto 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
| Field | Type | Notes |
|---|---|---|
ID | UUID | Key |
code | String(50) | Short template code |
name | String(255) | Required. Display name |
description | String(1000) | Optional |
useCase | Association → UseCases | Retail scenario category |
type | Association → PromotionTypes | Promotion type |
priorityGroup | Association → PriorityGroups | Optional |
tenantId | String(36) | Owning tenant (auto-stamped) |
minorVersion | Integer | Stamped 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 collection | Reached via |
|---|---|
TemplateConditions | PromotionTemplates |
TemplateConditionValueListItems | TemplateConditions |
TemplateConditionArticleListItems | TemplateConditions |
TemplateDiscountActions | PromotionTemplates |
TemplateDiscountActionScaledTiers | TemplateDiscountActions |
TemplateDiscountActionQuantityTiers | TemplateDiscountActions |
TemplateDiscountActionBundleComponents | TemplateDiscountActions |
TemplateDiscountActionArticleListItems | TemplateDiscountActions |
TemplateDiscountActionExclusions | TemplateDiscountActions |
TemplateDiscountActionExclusionArticleListItems | TemplateDiscountActionExclusions |
TemplateLoyaltyActions | PromotionTemplates |
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-…" }
| Aspect | Behaviour |
|---|---|
| Scope | PublicApi.Write (rejected 403 without it) |
| Returns | The new Promotion's ID (a UUID string) |
| Ownership | templateId must be owned by the caller's tenant; a foreign or unknown template returns 404 (indistinguishable — no existence oracle) |
400 | templateId missing |
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
| Field | Type | Notes |
|---|---|---|
ID | UUID | Key |
promotion | Association → Promotions | Required. One config per promotion |
pilotStartAt / pilotEndAt | DateTime | Required. The pilot window |
autoPromote | Boolean | When true, the auto-promote job flips PILOT → ACTIVE if uplift ≥ threshold |
autoPromoteThreshold | Decimal(5,2) | Minimum uplift % for auto-promote |
pilotShare | Decimal(3,2) | Fraction of pilot-group POS that receive the promotion. 1.00 = all (default). Range [0.00, 1.00] |
controlGroupSize | Integer | Absolute cap on control sample size. 0 = use all control POS (default). Range [0, 999999] |
tenantId | String(36) | Owning tenant (auto-stamped) |
minorVersion | Integer | Stamped on every 2xx response |
Composition children
| Child collection | Arm | Reached via |
|---|---|---|
PilotPosGroups | Pilot | PromotionPilotConfigs |
ControlPosGroups | Control | PromotionPilotConfigs |
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.
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).
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
GETof another tenant's row returns404(it is invisible, not forbidden). - Writes are validated: a
POST/PATCH/DELETEthat targets another tenant's row returns404; a body that claims a foreigntenantIdis rejected403. The caller cannot create or move a row into another tenant. - Composition children without their own
tenantId(e.g.CampaignPosGroups, theTemplate*children,PilotPosGroups/ControlPosGroups) are filtered through their parent — a directGETon 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 returns404. - 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:
| Status | Meaning |
|---|---|
400 | Validation error (bad body, empty required array, foreign ID in a link action) |
403 | Missing required scope, or write body claims a foreign tenantId |
404 | Row not found or not owned by the caller's tenant (no existence oracle) |
405 | Write attempted on a read-only collection (REST; Allow: GET) |
409 | Uniqueness conflict (e.g. second pilot config for a promotion) or DELETE of a non-terminal ImportJobs row |
413 | Bulk-import payload exceeds the per-tenant row cap |
429 | Too many concurrent bulk-import jobs for the tenant (Retry-After) |
OpenAPI spec
The machine-readable contract is published alongside the docs:
- REST:
openapi-public-api.json(OpenAPI 3.0) - OData V4 metadata (EDMX):
GET /api/v1/$metadata
The published spec is the source of truth for field-level shapes; this page documents behaviour, scopes, and the action contracts.
See Also
- Article Import API —
bulkImportbody schema,ImportJobspolling, idempotency, per-tenant limits - Promotions API (integrator guide) — reading promotion master data
- Authentication (integrator guide) — obtaining an XSUAA token
- Admin Service — the internal CRUD counterpart (Fiori, draft-enabled)