Wheel Spins
A provably fair prize wheel: your backend grants spins, the player spins, wins arrive as signed grants. The on-page wheel is a ~14KB drop-in widget — real casino motion, zero build step, themed by the Pulse team to match your site.
Flow at a glance
player clicks SPIN
-> your frontend calls YOUR backend
-> your backend calls Pulse (your API key, server-side)
-> Pulse draws the outcome (commit-reveal) and issues any signed grant
-> your backend returns { segment_index, prize_text } to the frontend
-> the widget animates to that exact segment1. Grant spins
Your promotion logic decides when a player earns spins (daily bonus, deposit reward, support goodwill — your call):
POST /v1/wheels/{wheel_id}/grants
{"player_id": "your-player-ref", "count": 1}
-> {"grants": [{"grant_id": "0d3c...", "commitment": "9f41...sha256"}]}The commitment is minted before the spin — surface it to players if you display fairness proofs. Granting is free; the spin is the billable action.
2. Render the wheel
Fetch the player's wheel state (server-side), then mount the widget:
GET /v1/wheels/{wheel_id}/players/{player_id}
-> {"segments": [{"label": "Nothing", ...}, {"label": "5 SC", ...}],
"open_spins": 2}<div id="wheel" style="width:420px;height:420px"></div>
<script src="https://api.playwithpulse.com/widget/pulse-wheel.js"></script>
<script>
const wheel = PulseWheel.mount(document.getElementById("wheel"), {
segments: SEGMENTS_FROM_YOUR_BACKEND,
onSpin: async () => {
const r = await fetch("/api/your-site/spin", { method: "POST" })
.then((res) => res.json());
return { index: r.segment_index, prizeText: r.prize_text };
},
});
</script>onSpin asks your backend; the widget just performs the theater — marquee chase, peg ticks, confetti — landing on the outcome the server already decided. Colors and branding are configured by the Pulse team during onboarding.3. Spin
POST /v1/wheels/{wheel_id}/spin
{"player_id": "your-player-ref",
"client_seed": "optional-player-entropy",
"idempotency_key": "uuid-per-click"}
-> {
"segment_index": 3,
"label": "50 SC",
"currency": "SC", "amount": 5000, // fixed part, if any
"percent_bp": 0, "percent_of": null, // percent part, if any
"fairness": {
"commitment": "9f41...", // published at grant time
"server_seed": "ab02...", // revealed now
"context": "tenant:wheel:player:grant:seed"
},
"grant": { "grant_id": "...", "signature": "...", ... },
"spins_remaining": 1
}Consumes the player's oldest open grant; 409 if they have none. Pass segment_index and your formatted prize string to wheel.spinTo(index, prizeText) (or return them from onSpin).
Fairness verification
Any spin can be independently re-derived:
- Check
SHA-256(server_seed) == commitment(published before the spin). - Compute
HMAC-SHA-256(key=server_seed, msg=context). - Interpret the digest as a big-endian integer, take it modulo the total segment weight, and walk the cumulative weights — that index is the outcome.
Deterministic and reproducible forever — a player, an auditor, or a regulator can verify without trusting Pulse or you.
Collecting the win
Paying spins append to your grant outbox (fixed amounts and/or percent bonuses). Verify the signature, credit your wallet, ack. The spin response includes the same grant inline for immediate UX; the outbox remains the source of truth for settlement.
Endpoint summary
| Endpoint | Purpose | Billable |
|---|---|---|
POST /v1/wheels/{id}/grants | give a player spins | no |
GET /v1/wheels/{id}/players/{pid} | segments + open spin count | no |
POST /v1/wheels/{id}/spin | resolve one spin | yes (1 token) |
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 "Wheel Spins" feature into this codebase.
Pulse is a gamification API for sweepstakes sites. Implement exactly the
architecture below - it is a hard security contract, not a suggestion.
## Non-negotiable rules
1. The Pulse API key (env var PULSE_API_KEY, format "prw_...") is used
ONLY in backend code. It must never appear in frontend bundles,
browser requests, or client-side env vars.
2. The frontend widget is presentation only. It never calls Pulse
directly. All Pulse calls go: frontend -> our backend -> Pulse.
3. Player balances live in OUR wallet system. Pulse pays via signed
"grants" that we verify, apply, and acknowledge. Never credit a
player from a frontend message or an unverified grant.
4. Every mutating Pulse call sends an idempotency_key (a UUID minted per
user action) so retries can never double-spend or double-pay.
## Pulse API (base https://api.playwithpulse.com, header
## "Authorization: Bearer <PULSE_API_KEY>")
- POST /v1/wheels/{wheel_id}/grants
body {"player_id": str, "count": int}
-> {"grants": [{"grant_id", "commitment"}]}
Gives a player free spins (call from our promo logic). Not billed.
- GET /v1/wheels/{wheel_id}/players/{player_id}
-> {"segments": [{"label", ...}], "open_spins": int}
- POST /v1/wheels/{wheel_id}/spin
body {"player_id": str, "client_seed": str?, "idempotency_key": str}
-> {"segment_index": int, "label": str, "currency": str,
"amount": int, "percent_bp": int, "percent_of": str|null,
"fairness": {"commitment", "server_seed", "context"},
"grant": {...}?, "percent_grant": {...}?, "spins_remaining": int}
409 means the player has no open spins. Amounts are integers in our
smallest currency unit (e.g. 5000 = 50.00 SC).
- GET /v1/outbox -> {"grants": [...]} (all unacknowledged grants)
- POST /v1/outbox/{grant_id}/ack (after our wallet applied it)
## Build these pieces
A. Backend endpoint POST /api/pulse/wheel/spin (our app, player must be
authenticated). It mints a UUID idempotency key, calls the Pulse spin
endpoint with our player's stable id, and returns to the frontend
ONLY: {segment_index, prize_text, spins_remaining}. Format prize_text
from the response: amount>0 -> "+{amount/100:.2f} {currency}";
percent_bp>0 -> "{percent_bp/100}% {percent_of} bonus"; neither ->
a house "no win" phrase. Map Pulse 409 to a friendly "no spins left".
B. Backend endpoint GET /api/pulse/wheel/state returning the Pulse
player-state response (segments + open_spins) for the current player.
C. Frontend page section:
<div id="pulse-wheel" style="width:420px;height:420px"></div>
<script src="https://api.playwithpulse.com/widget/pulse-wheel.js"></script>
Then: PulseWheel.mount(el, { segments, onSpin }) where segments comes
from endpoint B and onSpin calls endpoint A and returns
{ index: segment_index, prizeText: prize_text }. The widget animates
the outcome; it decides nothing.
D. An outbox worker (cron or loop, every 30-60s): GET /v1/outbox; for
each grant verify the HMAC signature, apply it to our wallet
idempotently keyed by grant_id, then POST the ack. Signature check:
payload = grant minus the "grant_id" and "signature" fields;
canonical = JSON dump with keys sorted, separators ("," ":") and no
whitespace; expected = HMAC-SHA256(key=PULSE_WEBHOOK_SECRET,
msg=canonical) hex digest; constant-time compare with
grant["signature"]. Grants with kind "fixed" credit
{currency, amount}; kind "percent" store {applies_to, basis_points}
for our purchase flow to consume. Reject (log, do not ack) any grant
that fails verification.
## Acceptance checklist (verify each before declaring done)
- [ ] Grep the built frontend assets: PULSE_API_KEY and
api.playwithpulse.com/v1 appear nowhere client-side (the widget
script URL is the only Pulse reference).
- [ ] Spinning twice with the same idempotency key returns the identical
response and consumes one spin total.
- [ ] A spin when open_spins=0 shows the friendly error, not a 500.
- [ ] A tampered grant signature is rejected and NOT acked or credited.
- [ ] Wallet credit is idempotent by grant_id (replaying the outbox does
not double-credit).
- [ ] The widget renders, spins on click, lands on the returned
segment_index, and shows prize_text.
Ask me for: PULSE_API_KEY, PULSE_WEBHOOK_SECRET, our wheel_id, how to
resolve the authenticated player's stable player_id, and our wallet
credit function/location.