Context
Coupons are the second instrument in the module, and they are not points. A point is a balance a member accumulates; a coupon is a bearer token with its own lifecycle, its own fraud surface and its own redemption path. Voucherify built its business on the difference, and the operational lessons are well established: unique codes must be generated in bulk without collisions, and validation must be a single fast call because it happens at a counter. The fraud surface is what makes this its own feature rather than a field on a reward. A code that can be guessed, shared, or redeemed twice is a direct revenue loss, and each of those is a different control.Scope (normative)
loyalty.coupon_campaigns: generic (one shared code) or unique (one code per member).- Bulk generation of unique codes with a collision-free alphabet and a documented entropy budget.
loyalty.coupon_codeswith statesissued → redeemed | expired | void.- Validation endpoint: is this code valid, for this member, right now.
- Redemption, burning the code atomically.
issue_couponas a rule effect, wiring into FS-LOY-0004’s executor.
Non-scope (normative)
- Whether a coupon may be combined with another discount — FS-LOY-0008.
- Discounting a cart price. A coupon here records that it was redeemed; computing a new price is
apply_discount, F2 (DEC-H4). - Distribution. Sending the code to the member is
messaging, reached through the issued event.
Behaviour (normative)
- A code is 12 characters over Crockford Base32 (no I, L, O, U) by default, unique per tenant, case-insensitive on validation. A campaign may drop to 10 where the code has to be dictated by phone. Twelve characters give ~1.15×10¹⁸ combinations, so bulk generation never fights itself even at hundreds of millions of codes across all tenants (OQ-LOY-11).
- Entropy is a stated budget, not an accident: enough that guessing is infeasible against the campaign’s rate limits. Sequential or predictable codes are FORBIDDEN.
- Bulk generation is idempotent per request and never produces a collision. A retry of a generation request does not create a second batch.
- Validation is read-only and never mutates. It answers valid or invalid with a typed reason — expired, already redeemed, wrong member, campaign inactive — because a cashier needs to know which.
- Redemption burns the code in one transaction: state transition, outbox event, audit row. A second redemption of the same code fails with a typed error, always.
- A code is never deleted. Cancelling it means the
voidstate. - A unique-code campaign binds each code to one contact. A generic campaign does not, and its per-contact usage limit is enforced through redemption history.
Data (normative)
API (normative)
Events (normative)
loyalty.coupon.issued, loyalty.coupon.redeemed, loyalty.coupon.expired — all available as
outgoing webhooks. issued is what messaging listens to in order to deliver the code.
Acceptance criteria (normative)
- Generating 100 000 unique codes produces zero collisions and completes within the documented budget.
- Retrying a generation request with the same idempotency key does not create a second batch.
- Validation returns a distinct typed reason for each invalid case and writes nothing.
- Redeeming the same code twice concurrently (50 parallel requests) succeeds exactly once.
- A code bound to member A validated for member B is rejected with
WRONG_MEMBER. - Expiry moves a code to
expiredand emits the event exactly once. - Negative: no endpoint deletes a coupon code.
Execution
Synchronous command for validation and redemption; bulk generation is a queued job for batches above a documented threshold, idempotent viacore.processed_jobs.
Open questions
None outstanding. Every question this spec carried was answered in the consolidated register (../../../design/open-questions-v1.md, v1.1) and folded
into the normative sections above.