Skip to main content

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.
activateCREATED → ACTIVEVerifies customer ownership when the code is bound to a customerId. If validTo has already passed, the code is auto-expired to EXPIRED instead.
reserveACTIVE → RESERVEDReturns a reservationId. Holds the code so a concurrent transaction can't take it.
redeemRESERVED → REDEEMEDLooked up by reservationId. Not idempotent — a second call returns 409. Writes a redemption log.
cancelRESERVED → ACTIVELooked 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 — reserve → redeem / 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"
}
caution
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​