Zum Hauptinhalt springen

Coupon lifecycle

DRE coupons move through a small, strict state machine. This page explains the data model, the states, which endpoint causes which transition, how codes are generated, and how post-purchase vouchers are issued. The exact request/response shapes for the endpoints are on the wire contract; the on-the-wire coupons field shape is on POS API — Coupons wire-format.

Data model

EntityWhat it isKey fields
CouponTypeThe template. Status is ACTIVE / INACTIVE.type (GENERIC / INDIVIDUAL), prefix, codeLength, maxRedemptions, maxRedemptionsPerCustomer, validFrom/validTo, isStackable
CouponCodeAn individual issued code. Has the 6-state status below.code, status, customerId, reservationRef
CouponCodeBatchTracks a code-generation run.numberOfCodes, status (PENDING/GENERATING/COMPLETED/FAILED)
CouponRefillScheduleAuto-refill config (one per type/tenant).refillThreshold, numberOfCodes, enabled (default false)

A coupon unlocks a promotion through the many-to-many CouponTypePromotions link: CouponType ⇄ Promotion. When a code is presented on evaluate, the coupon resolver validates it and loads its linked promotion(s) into the candidate pool (tagging them triggeredByCoupon). A coupon-triggered promotion skips the validity/store pre-filter — the coupon itself is the trigger — but must still be ACTIVE and belong to your tenant.

The state machine

REDEEMED, EXPIRED, and CANCELLED are terminal — there is no transition out of them.

How a code is born

The starting state depends on how the code is created:

OriginStarts asNeeds activation?
POST /pos/coupons/issue (CRM / marketing)CREATEDYes — customer activates in-app
Batch generation (admin)ACTIVENo — immediately usable
Auto-refillACTIVENo
Post-purchase (generated at confirm)ACTIVENo

So activation is only for the issuance flow: a code issued to a customer is dormant (CREATED) until they activate it in their app.

Which endpoint causes which transition

EndpointTransitionNotes
validatenone (read-only)Checks existence, ACTIVE status, parent type ACTIVE, validity window, redemption limits, linked promotions.
activateCREATEDACTIVEVerifies customer ownership when the code is bound to a customerId. If validTo has already passed, the code is auto-expired to EXPIRED instead.
reserveACTIVERESERVEDReturns a reservationId. Holds the code so a concurrent transaction can't take it.
redeemRESERVEDREDEEMEDLooked up by reservationId. Not idempotent — a second call returns 409. Writes a redemption log.
cancelRESERVEDACTIVELooked up by reservationId. Idempotent — calling it on an already-released hold returns success.

Two ways a coupon gets redeemed

There are two redemption paths. Pick by your checkout model.

A. Explicit POS hold — reserveredeem / cancel

For a till that wants to lock a coupon the moment it is scanned, then commit or release it at payment:

POST /pos/coupons/reserve
{
"code": "WELCOME15",
"transactionId": "TXN-1"
}
{
"reservationId": "8f1c9b2e-...-a77",
"reserved": true,
"expiresAt": "2026-06-07T14:45:00Z",
"message": "Coupon reserved"
}
POST /pos/coupons/redeem
{
"reservationId": "8f1c9b2e-...-a77",
"transactionId": "TXN-1"
}
{
"redeemed": true,
"message": "Coupon redeemed"
}
vorsicht
expiresAt is informational

The 15-minute expiresAt is returned for your convenience but is not enforced by a server-side timer — an abandoned reservation stays RESERVED until you cancel it. Always cancel a hold you decide not to redeem, so the code returns to ACTIVE for the next customer.

If you pass coupon codes on evaluate/confirm, you do not call the reserve/redeem endpoints yourself. At confirm, DRE writes an internal soft-hold and the asynchronous side-effects worker flips each code to REDEEMED and writes the redemption log. You observe the result through the side-effects poll (couponsRedeemed). On terminal failure the internal hold is released and the code is not consumed. This is the path most integrations should use — it keeps coupon redemption atomic with the rest of the transaction commit.

Redemption limits

Limits live on the CouponType (0 = unlimited):

  • maxRedemptions — total redemptions across all customers.
  • maxRedemptionsPerCustomer — per-customer cap.

A single-use coupon is maxRedemptions: 1; the code becomes terminal (REDEEMED) after one use. A GENERIC coupon shares one code across customers (its maxRedemptions bounds total uses); an INDIVIDUAL coupon is unique per customer.

Issuing coupons (CRM / marketing)

POST /pos/coupons/issue creates personalized codes for one or many customers. Each starts CREATED and must be activated by the customer before use.

POST /pos/coupons/issue
{
"couponTypeName": "WELCOME_2026",
"customerIds": ["CUST-4711", "CUST-4712"],
"reason": "loyalty-onboarding"
}
{
"issuedCount": 2,
"failedCount": 0,
"issuedCoupons": [
{ "code": "WELCOME-7F3K9A", "customerId": "CUST-4711" },
{ "code": "WELCOME-Q2M8XZ", "customerId": "CUST-4712" }
],
"failures": [],
"message": "Issued 2 coupons"
}

Rate limited to 1000 issuances per minute per tenant. The customer then activates their code:

POST /pos/coupons/activate
{
"code": "WELCOME-7F3K9A",
"customerId": "CUST-4711"
}
{
"activated": true,
"code": "WELCOME-7F3K9A",
"message": "Coupon activated",
"validUntil": "2026-12-31T23:59:59Z"
}

Post-purchase coupon issuance

A promotion with a POST_PURCHASE_COUPON action issues a voucher for a future visit. This section covers configuration, the full issuance flow, the attribution fields available via the Public API, and how to track redemption rates.

What it does

At evaluate, no code is generated. The face value is attributed to the promotion's totalDiscount so the receipt can show "you earned a €5 voucher". At confirm, the side-effects worker asynchronously claims a code from the target CouponType's pool (or mints one on the fly if the pool is empty), stamps attribution metadata on it, and returns the result in the side-effects poll under postPurchaseCoupons[].

The generated code starts ACTIVE (no activation needed). It can be used by the customer on their next visit via the normal evaluate/confirm coupon flow.

Configuration

Create a promotion with a POST_PURCHASE_COUPON discount action:

Config fieldValueNotes
Promotion typeAnyZero conditions = always-on (matches every basket)
Action typePOST_PURCHASE_COUPON
targetCouponTypeID of an INDIVIDUAL coupon typeMust be INDIVIDUAL (single-use). GENERIC (multi-use) is rejected on save
discountValueFace value (e.g. 5.00)Shown on receipt via totalDiscount
Always-on post-purchase promotions

An always-on voucher promotion needs no conditions. Zero-condition promotions automatically match every basket. If you want a threshold gate (e.g. "spend €50"), add a single RECEIPT_AMOUNT condition with minAmount: 50.

Pre-generate a pool for production throughput

Post-purchase issuance uses a pool-first strategy: if an ACTIVE code with issuedAt IS NULL exists for the target type, it is claimed instantly. If the pool is empty, a code is minted on the fly — which is slower. Create a CouponRefillSchedule with enabled: true for the target coupon type to keep the pool stocked. A missing or disabled refill schedule triggers a non-blocking admin warning when you save the action.

Issuance flow

Key idempotency guarantee: the worker checks for an existing CouponCodes row where issuingTransactionId = thisTxn AND issuingPromotionId = thisPromo AND couponType = target before acting. Worker retries do not double-issue.

Side-effects poll response

{
"status": "COMPLETED",
"postPurchaseCoupons": [
{
"code": "VOUCHER-7F3K9A",
"couponTypeId": "c1d2e3f4-...",
"customerId": "CUST-4711",
"issuedAt": "2026-06-10T09:15:33.112Z"
}
]
}
Anonymous baskets

customerId may be null for anonymous receipts. issuedAt is still stamped — the code itself is the bearer token, printed on the physical receipt.

Attribution fields on CouponCodes

Every post-purchase code gets four attribution fields stamped at issuance:

FieldTypeValue for post-purchase codesValue for other codes
issuedAtTimestampWhen the code was handed to the customer at this checkoutnull — still in the pool
issuingPromotionIdString(36)ID of the promotion that issued itnull
issuingTransactionIdString(50)POS transaction ID of the issuing basket (idempotency key)null
issuanceChannelString enumPOST_PURCHASEBATCH / MANUAL / EXTERNAL / null (legacy)

See CouponCodes fields in the Public API reference for the full field table, and Issuance channels for the issuanceChannel enum.

Querying issued codes via the Public API

The /api/v1/CouponCodes collection exposes the attribution fields. Use OData $filter to slice the population:

All post-purchase codes issued (handed out):

GET /api/v1/CouponCodes?$filter=issuanceChannel eq 'POST_PURCHASE' and issuedAt ne null

Codes issued by a specific promotion:

GET /api/v1/CouponCodes?$filter=issuingPromotionId eq '<promotionId>'

Codes issued in a specific basket (e.g. for receipt reprint):

GET /api/v1/CouponCodes?$filter=issuingTransactionId eq 'TXN-2026-001'

Tracking redemption

CouponCodes rows carry a back-navigation to RedemptionLogs via the redemptions association. Analytical redemption KPIs — issued count, redeemed count, and average days to redemption — are surfaced in the Coupon Analytics report in the admin UI. Click a coupon type to view its post-purchase metrics on the coupon type overview page.

See also Promotion scenarios & actions.

Code generation & auto-refill

For GENERIC/pooled coupon types, codes are produced in batches:

  • Synchronous for batches of ≤ 1000 codes (the call blocks until done).
  • Asynchronous above 1000 codes (returns immediately, status GENERATING, processed in background chunks). Generated codes are inserted ACTIVE.

Auto-refill keeps a pool stocked. A background job ticks every minute and, for each enabled CouponRefillSchedule, tops the pool back up when the count of ACTIVE codes drops below refillThreshold. Refill schedules are enabled: false by default — turn one on when you want a self-replenishing pool. Refill is distributed-lock-guarded, so only one instance refills a given type at a time.

See also