Skip to main content

Rewards & fulfillment

A prize is the catalog item (with stock + delivery policy). A reward is one awarded unit with its own lifecycle. Fulfillment is how an owed reward is actually delivered.

Prize → reward lifecycle

Prizes carry constraints (max_per_user / max_per_day, enforced inside the Play txn — a capped prize the wheel lands on is dropped to no-win) and a fulfillment policy.

Delivery is configured, not coded

Principle: our system is the source of truth for what is owed; an orchestrator owns how it is delivered.

// prize.fulfillment
{ "redemption_mode": "on_claim", // instant | on_claim | manual | exchange
"channel": "external_workflow", // voucher_code | sms | zns | email | points_credit |
// physical_shipping | crm_sync | ecommerce | external_workflow
"channel_config": { "webhook_url": "https://n8n…", "hmac_secret_ref": "secret://n8n" },
"retry": { "max_attempts": 5, "backoff": "exponential" } }

A FulfillmentProvider registry maps each channel to an implementation (same pluggable pattern as reward handlers). Built-in voucher_code pops a code from an imported pool; others are stubbed; external_workflow hands off to n8n.

The transactional outbox

Reliability comes from writing a fulfillment_tasks row in the same DB transaction as the reward + stock deduction — so a delivery is never lost or duplicated.

A dispatcher worker polls due tasks (SELECT … FOR UPDATE SKIP LOCKED, multi-replica safe), invokes the provider with exponential backoff, and dead-letters after the attempt budget. Completing a task flips its reward to fulfilled.

n8n via external_workflow

Our outbox tracks the lifecycle; n8n owns the steps. See the full sequence in Flows → Rewards & fulfillment.

Redemption modes

ModeWhen delivery happens
instantautomatically on win
on_claimwhen the player calls claim
manualstaff approval queue
exchangelucky-item milestone redeem (see Wallet & milestones)