Promo Codes

A redeemable code that hands out a reward — bonus currency, free wheel spins, raffle tickets, or any combination. Redemption is the one money movement; the code itself is just an entitlement, and cost is bounded by exact caps, not odds.

Two bindings, one engine

POST /v1/promos/{campaign_id}/issue

{"player_id": "..."}  ->  {"code": "reactivation-9F3K2A"}

Redeeming

POST /v1/promos/{code}/redeem

{"player_id": "...", "idempotency_key": "uuid-per-submit"}

-> {"status": "redeemed", "code": "WELCOME50", "campaign_id": "WELCOME50",
    "rewards": [
      {"type": "currency", "currency": "SC", "amount": 5000,
       "label": "50 SC Welcome"},                              // -> signed grant, outbox
      {"type": "free_spins", "wheel_id": "daily", "count": 5}, // -> open spins, no credit
      {"type": "raffle_tickets", "raffle_id": "weekly_draw",
       "count": 3}                                             // -> ticket ledger, NO grant
    ]}

The player types the code into your UI; your backend calls Pulse. 404 = unknown code; 409= real code that can't be redeemed now (inactive, expired, fully redeemed, per-player limit, wrong player) with a human-readable reason.

rewards[] is a manifest, not a payment. Currency settles through the outbox exactly like a wheel or streak win; free spins appear as open spins on the named wheel; raffle tickets are earned only — no grant, never credit them. One toast per row, then refresh the widgets.

Cost is capped, not probabilistic

A promo's ceiling is exactly max_redemptions × reward — plus per_player_limit and an optional start/expiry window. Failed attempts (404/409) are never billed; only successful redemptions meter. So a launch code can be printed on a billboard with a known worst case.

Reads

GET /v1/promos/{id}
-> {binding, reward, max_redemptions, per_player_limit,
    starts_at, expires_at, active, redemptions, remaining}

Endpoint summary

EndpointPurposeBillable
POST /v1/promos/{code}/redeemredeem a codeyes (1 token, successful only)
POST /v1/promos/{id}/issuemint a per-player codeno
GET /v1/promos/{id}config + redemption statsno
Codes, rewards, caps, and windows are authored in Pulse Studio's Promo Codes tab (or with the Pulse team). Your integration is the redeem call — and, for unique campaigns, issue.

Integrate with your coding agent

Building with Claude, Cursor, Copilot, or another AI agent? Paste this prompt — it contains the complete contract, the security rules, and an acceptance checklist, so your agent can implement the integration without reading these docs.

Integrate the Pulse Rewards "Promo Codes" feature into this codebase. Pulse
is a gamification API for sweepstakes sites. Follow this architecture exactly
- it is a hard security contract.

## Non-negotiable rules
1. The Pulse API key (env PULSE_API_KEY, "prw_...") is backend-only: never in
   frontend bundles or browser requests. The player types a code into OUR UI;
   OUR backend calls Pulse. The browser never talks to Pulse.
2. Redemption's rewards[] is a manifest, not a payment. Each row settles by
   its own path and ONLY that path:
   - {type:"currency", ...} -> a signed grant lands in the outbox; verify,
     credit the wallet, ack - identical to wheel/streak wins.
   - {type:"free_spins", wheel_id, count} -> open spins added on Pulse's side;
     just refresh the wheel UI. No wallet credit.
   - {type:"raffle_tickets", raffle_id, count} -> Pulse's ticket ledger only.
     NO grant, NO wallet credit, render a toast.
3. Never invent or validate codes locally. POST to Pulse and render its
   answer; 404/409 map to friendly errors.
4. Every redeem sends an idempotency_key (UUID per submit).

## Pulse API (base https://api.playwithpulse.com, header
## "Authorization: Bearer <PULSE_API_KEY>")
- POST /v1/promos/{code}/redeem   {player_id, idempotency_key}
    -> {status:"redeemed", code, campaign_id, rewards:[...]}
    404 unknown code | 409 unavailable: inactive, expired, not started,
    fully redeemed, per-player limit reached, wrong player (unique codes),
    already redeemed. 409 detail is human-readable - surface it politely.
    Only a SUCCESSFUL redemption is billed; failed attempts cost nothing.
- POST /v1/promos/{campaign_id}/issue   {player_id}
    -> {code: "campaign-XXXXXX"}   (unique campaigns only: mints a
    single-use code bound to that player - for OUR promo emails/CRM sends)
- GET /v1/promos/{id} -> {binding, reward, max_redemptions, per_player_limit,
    starts_at, expires_at, active, redemptions, remaining}

## Build these pieces
A. Backend POST /api/pulse/promo/redeem (player authenticated): mint a UUID
   idempotency key, call Pulse with the player's stable id and the trimmed,
   uppercased code. Map: 404 -> "That code doesn't exist", 409 -> the detail
   text, 200 -> the rewards[] summary.
B. A "Redeem a code" input in the player's rewards/wallet area: text field +
   submit, disabled while in flight; on success show one toast per rewards[]
   row using its label ("+50 SC", "+5 Free Spins", "+3 Raffle Tickets"), then
   refresh wallet/wheel/raffle widgets so the new balances show.
C. Settlement: NOTHING new. Currency rows arrive in the same outbox worker
   as every Pulse feature (verify HMAC, credit idempotently by grant_id,
   ack). Free spins and tickets require no settlement.

## Acceptance checklist
- [ ] Redeeming a valid code twice: first succeeds, second shows the 409
      detail; exactly one wallet credit.
- [ ] Replaying the same idempotency_key returns the identical body.
- [ ] free_spins and raffle_tickets rows create NO wallet credit and NO
      outbox activity.
- [ ] A 404/409 from Pulse renders a friendly message, not an error page.
- [ ] No PULSE_API_KEY in built frontend assets.
Ask me for: PULSE_API_KEY, our configured promo ids, the player id mapping,
and where the wallet credit function lives.