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
- shared — one public code; the id is the code (
WELCOME50). Print it anywhere. - unique— a campaign that mints per-player, single-use codes on demand — built for "send player123 a personal comeback code" flows:
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
| Endpoint | Purpose | Billable |
|---|---|---|
POST /v1/promos/{code}/redeem | redeem a code | yes (1 token, successful only) |
POST /v1/promos/{id}/issue | mint a per-player code | no |
GET /v1/promos/{id} | config + redemption stats | no |
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.