What you'll build
A four-endpoint backend a real raffle can run on. The conceptual Building Fair Raffles page stays at the "here's the recipe" level — this one is the runnable codebase.
POST /raffles— open a raffle. CallsPOST /api/commitand returns the publicserverHashyou'll pin somewhere your community sees.POST /raffles/:id/entries— accept entries while the raffle is open.POST /raffles/:id/draw— close entries and reveal. CallsPOST /api/revealagainst the committed seed and stores the winner.GET /raffles/:id— public page: shows the published hash, entry count, winner, the outcome'spermalink, and (once available) a Merkle inclusion proof.POST /hooks/provable— receive theoutcome.createdwebhook so you can mark the draw "delivered" without polling.
Project layout
raffle-backend/
├── package.json
├── .env # PROVABLE_API_KEY=pk_live_… PROVABLE_WEBHOOK_SECRET=whsec_…
├── server.js # everything below
└── data.json # tiny JSON store; swap for Postgres in production
npm init -y
npm install express
node --env-file=.env server.js
The shared HTTP helper
const BASE = "https://api.provable.io";
const KEY = process.env.PROVABLE_API_KEY;
async function provable(method, path, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
"x-api-key": KEY,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) throw new Error(`provable ${method} ${path}: ${res.status} ${text}`);
return text ? JSON.parse(text) : null;
}
1. Open a raffle (the commit step)
import express from "express";
import crypto from "node:crypto";
import fs from "node:fs/promises";
const app = express();
// Register the webhook route BEFORE express.json() so the raw body survives
// for HMAC verification. The global JSON parser below handles every other route.
app.post(
"/hooks/provable",
express.raw({ type: "application/json" }),
handleProvableWebhook, // defined further down
);
app.use(express.json());
const STORE = "./data.json";
const load = async () => JSON.parse(await fs.readFile(STORE, "utf8").catch(() => "{}"));
const save = async (s) => fs.writeFile(STORE, JSON.stringify(s, null, 2));
app.post("/raffles", async (req, res) => {
const { title } = req.body;
const commit = await provable("POST", "/api/commit");
const id = crypto.randomUUID();
const store = await load();
store[id] = {
id, title, status: "open", entries: [],
commitId: commit.commitId,
serverHash: commit.serverHash,
expiresAt: commit.expiresAt,
};
await save(store);
// Publish the serverHash + expiresAt anywhere your community sees them.
res.status(201).json({
id, title,
serverHash: commit.serverHash,
revealBy: new Date(commit.expiresAt).toISOString(),
verify: `${req.headers.host ? `https://${req.headers.host}` : ""}/raffles/${id}`,
});
});
Note the expiresAt — the commit must be revealed within COMMIT_TTL_MS (default 10 minutes). For a same-day raffle this is fine; for a multi-day one, only commit at the start of the final reveal window.
2. Accept entries
app.post("/raffles/:id/entries", async (req, res) => {
const { name } = req.body;
const store = await load();
const raffle = store[req.params.id];
if (!raffle) return res.status(404).end();
if (raffle.status !== "open") return res.status(409).json({ error: "raffle closed" });
raffle.entries.push({ name, joinedAt: Date.now() });
await save(store);
res.json({ entryNumber: raffle.entries.length });
});
3. Draw the winner (the reveal step)
Use the raffle id as the clientSeed — public, unique per raffle, and stable. Send an Idempotency-Key so a network blip during reveal can't double-bill or fail your retry.
app.post("/raffles/:id/draw", async (req, res) => {
const store = await load();
const raffle = store[req.params.id];
if (!raffle) return res.status(404).end();
if (raffle.status !== "open") return res.status(409).json({ error: "already drawn" });
if (raffle.entries.length === 0) return res.status(400).json({ error: "no entries" });
raffle.status = "drawing";
await save(store);
const idempotencyKey = `raffle:${raffle.id}:draw`;
const reveal = await fetch(`${BASE}/api/reveal`, {
method: "POST",
headers: {
"x-api-key": KEY,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
commitId: raffle.commitId,
clientSeed: `raffle:${raffle.id}`,
endpoint: "ints",
params: { count: 1, min: 1, max: raffle.entries.length },
}),
}).then(async (r) => {
if (!r.ok) throw new Error(`reveal failed: ${r.status} ${await r.text()}`);
return r.json();
});
const winnerIndex = reveal.outcome[0] - 1;
raffle.status = "drawn";
raffle.winner = raffle.entries[winnerIndex];
raffle.serverSeed = reveal.serverSeed;
raffle.outcome = reveal.outcome;
raffle.cursor = reveal.cursor;
raffle.nonce = reveal.nonce;
raffle.drawnAt = Date.now();
await save(store);
res.json({
winner: raffle.winner,
outcomeId: `raffle:${raffle.id}:${reveal.cursor}:${reveal.nonce}`,
verify: { serverHash: raffle.serverHash, serverSeed: reveal.serverSeed, clientSeed: `raffle:${raffle.id}` },
});
});
4. Public verification page
Anyone can hit this without auth. It includes a deep link to the outcome's permalink on provable.io, which renders a server-side verification badge.
app.get("/raffles/:id", async (req, res) => {
const store = await load();
const r = store[req.params.id];
if (!r) return res.status(404).end();
let proof = null;
if (r.status === "drawn") {
// Tomorrow (or later today, UTC) the Merkle root for the draw day will exist.
const day = new Date(r.drawnAt).toISOString().slice(0, 10);
const outcomeId = `raffle:${r.id}:${r.cursor}:${r.nonce}`;
const resp = await fetch(`${BASE}/api/merkle/${day}/proof/${outcomeId}`);
if (resp.ok) proof = await resp.json();
}
res.json({
title: r.title,
status: r.status,
entries: r.entries.length,
winner: r.winner ?? null,
commitment: { serverHash: r.serverHash, revealBy: new Date(r.expiresAt).toISOString() },
reveal: r.status === "drawn" ? {
serverSeed: r.serverSeed,
clientSeed: `raffle:${r.id}`,
outcome: r.outcome,
permalink: `https://provable.io/o/by-id?clientSeed=raffle:${r.id}&cursor=${r.cursor}&nonce=${r.nonce}`,
} : null,
inclusionProof: proof, // null until the day's root is published
});
});
5. Webhook receiver (optional but recommended)
Verifying the X-Provable-Signature HMAC is the same pattern as the Webhooks guide. The interesting part for a raffle is that the outcome.created event lets you confirm "yes, the reveal really hit storage" without polling.
The route itself was already mounted at the top of server.js with express.raw({ type: "application/json" }) — that's deliberate. If you instead registered it after app.use(express.json()), the global JSON parser would have already consumed the body and req.body would be a parsed object, breaking byte-exact HMAC verification. Define the handler now and the wiring from step §"Project layout" picks it up:
async function handleProvableWebhook(req, res) {
// req.body is a Buffer because express.raw() ran for this route.
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.PROVABLE_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
const got = Buffer.from(req.get("X-Provable-Signature") || "");
const exp = Buffer.from(expected);
if (got.length !== exp.length || !crypto.timingSafeEqual(got, exp)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.event === "outcome.created" && event.data.clientSeed.startsWith("raffle:")) {
const raffleId = event.data.clientSeed.slice("raffle:".length);
const store = await load();
const r = store[raffleId];
if (r) { r.confirmedAt = Date.now(); await save(store); }
}
res.status(200).send("ok");
}
What you've built
Five hundred lines of code that gives every participant three independent ways to audit a winner:
- The
serverHashwas published before entries closed. - The revealed
serverSeedhashes to it, and re-running the generator with the publishedclientSeedreproduces the same winner index. - The outcome appears in the day's published Merkle root, with a sibling chain that collapses to the same root anyone can fetch from
/api/merkle/<date>.
That's the bar a "provably fair" raffle has to meet. Anything less is "trust us."
Production checklist
- Move
data.jsonto Postgres (Provable.io itself uses Postgres for the same data shape). - Lock down the API key with an IP allowlist.
- Wrap the reveal call in your application's own retry — see Using
Idempotency-Key. - Use test keys in CI so your tests don't burn quota.