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
| Entity | What it is | Key fields |
|---|---|---|
| CouponType | The template. Status is ACTIVE / INACTIVE. | type (GENERIC / INDIVIDUAL), prefix, codeLength, maxRedemptions, maxRedemptionsPerCustomer, validFrom/validTo, isStackable |
| CouponCode | An individual issued code. Has the 6-state status below. | code, status, customerId, reservationRef |
| CouponCodeBatch | Tracks a code-generation run. | numberOfCodes, status (PENDING/GENERATING/COMPLETED/FAILED) |
| CouponRefillSchedule | Auto-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:
| Origin | Starts as | Needs activation? |
|---|---|---|
POST /pos/coupons/issue (CRM / marketing) | CREATED | Yes — customer activates in-app |
| Batch generation (admin) | ACTIVE | No — immediately usable |
| Auto-refill | ACTIVE | No |
Post-purchase (generated at confirm) | ACTIVE | No |
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
| Endpoint | Transition | Notes |
|---|---|---|
validate | none (read-only) | Checks existence, ACTIVE status, parent type ACTIVE, validity window, redemption limits, linked promotions. |
activate | CREATED → ACTIVE | Verifies customer ownership when the code is bound to a customerId. If validTo has already passed, the code is auto-expired to EXPIRED instead. |
reserve | ACTIVE → RESERVED | Returns a reservationId. Holds the code so a concurrent transaction can't take it. |
redeem | RESERVED → REDEEMED | Looked up by reservationId. Not idempotent — a second call returns 409. Writes a redemption log. |
cancel | RESERVED → ACTIVE | Looked 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"
}
expiresAt is informationalThe 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.
B. Automatic via confirm (recommended)
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 field | Value | Notes |
|---|---|---|
| Promotion type | Any | Zero conditions = always-on (matches every basket) |
| Action type | POST_PURCHASE_COUPON | |
targetCouponType | ID of an INDIVIDUAL coupon type | Must be INDIVIDUAL (single-use). GENERIC (multi-use) is rejected on save |
discountValue | Face value (e.g. 5.00) | Shown on receipt via totalDiscount |
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.
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"
}
]
}
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:
| Field | Type | Value for post-purchase codes | Value for other codes |
|---|---|---|---|
issuedAt | Timestamp | When the code was handed to the customer at this checkout | null — still in the pool |
issuingPromotionId | String(36) | ID of the promotion that issued it | null |
issuingTransactionId | String(50) | POS transaction ID of the issuing basket (idempotency key) | null |
issuanceChannel | String enum | POST_PURCHASE | BATCH / 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 insertedACTIVE.
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
- POS Service wire contract — request/response field tables for every coupon endpoint.
- POS API — Coupons wire-format — the
couponsfield object-array shape. - Evaluate → Confirm lifecycle — how redemption fits into the transaction commit.