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.

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:

  1. The serverHash was published before entries closed.
  2. The revealed serverSeed hashes to it, and re-running the generator with the published clientSeed reproduces the same winner index.
  3. 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

Next steps