Skip to main content

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_codes with states issued → redeemed | expired | void.
  • Validation endpoint: is this code valid, for this member, right now.
  • Redemption, burning the code atomically.
  • issue_coupon as 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)

  1. 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).
  2. 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.
  3. Bulk generation is idempotent per request and never produces a collision. A retry of a generation request does not create a second batch.
  4. 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.
  5. 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.
  6. A code is never deleted. Cancelling it means the void state.
  7. 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)

  1. Generating 100 000 unique codes produces zero collisions and completes within the documented budget.
  2. Retrying a generation request with the same idempotency key does not create a second batch.
  3. Validation returns a distinct typed reason for each invalid case and writes nothing.
  4. Redeeming the same code twice concurrently (50 parallel requests) succeeds exactly once.
  5. A code bound to member A validated for member B is rejected with WRONG_MEMBER.
  6. Expiry moves a code to expired and emits the event exactly once.
  7. 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 via core.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.

Changelog

Delivery record

Not implemented yet.