Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.fieldfunded.com/llms.txt

Use this file to discover all available pages before exploring further.

How to Build a Sports Betting Website from Scratch

This is a complete guide to building a functional sports betting website — from architecture decisions to deployment. By the end, you will have a working site that displays live odds, accepts bets, and settles them automatically. Tech stack: React frontend, Node.js backend, PostgreSQL database, FieldFunded API for odds and settlement.

Architecture Overview

Three layers, clean separation:
  • Frontend: Displays odds, handles bet slip UX, shows account history
  • Backend: Proxies API calls, validates bets, stores data, runs settlement
  • External: FieldFunded API for odds data and settlement, PostgreSQL for state

Step 1: Database Schema

Before writing any code, define your core tables:
-- Users
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email VARCHAR(255) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  balance DECIMAL(12,2) DEFAULT 0.00,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Bets
CREATE TABLE bets (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id),
  event_id VARCHAR(100) NOT NULL,
  event_name VARCHAR(255) NOT NULL,
  market_id VARCHAR(100) NOT NULL,
  market_name VARCHAR(100) NOT NULL,
  selection_id VARCHAR(100) NOT NULL,
  selection_name VARCHAR(100) NOT NULL,
  odds DECIMAL(8,2) NOT NULL,
  stake DECIMAL(12,2) NOT NULL,
  potential_payout DECIMAL(12,2) NOT NULL,
  status VARCHAR(20) DEFAULT 'pending',  -- pending, won, lost, refund
  settled_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Transactions (deposits, withdrawals, bet payouts)
CREATE TABLE transactions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id),
  type VARCHAR(20) NOT NULL,  -- deposit, withdrawal, bet, payout, refund
  amount DECIMAL(12,2) NOT NULL,
  bet_id UUID REFERENCES bets(id),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Indexes for common queries
CREATE INDEX idx_bets_user ON bets(user_id);
CREATE INDEX idx_bets_status ON bets(status);
CREATE INDEX idx_bets_event ON bets(event_id);
CREATE INDEX idx_transactions_user ON transactions(user_id);

Step 2: Backend — Odds Proxy

Never expose your API key to the frontend. Proxy all calls through your backend:
// server/routes/odds.js
const express = require("express");
const router = express.Router();

const API_KEY = process.env.FIELDFUNDED_API_KEY;
const BASE = "https://api.fieldfunded.com/v1";

// Cache odds for 5 seconds to reduce API calls
const cache = new Map();
const CACHE_TTL = 5000;

async function cachedFetch(url) {
  const now = Date.now();
  if (cache.has(url) && now - cache.get(url).time < CACHE_TTL) {
    return cache.get(url).data;
  }

  const res = await fetch(url, {
    headers: { "X-API-Key": API_KEY },
  });

  if (!res.ok) throw new Error(`API error: ${res.status}`);
  const data = await res.json();
  cache.set(url, { data, time: now });
  return data;
}

// GET /api/events?sport=soccer
router.get("/events", async (req, res) => {
  try {
    const sport = req.query.sport || "";
    const url = `${BASE}/events${sport ? `?sport=${sport}` : ""}`;
    const data = await cachedFetch(url);
    res.json(data);
  } catch (err) {
    res.status(502).json({ error: "Failed to fetch events" });
  }
});

// GET /api/events/:id/odds
router.get("/events/:id/odds", async (req, res) => {
  try {
    const data = await cachedFetch(
      `${BASE}/events/${req.params.id}/odds`
    );
    res.json(data);
  } catch (err) {
    res.status(502).json({ error: "Failed to fetch odds" });
  }
});

// GET /api/live
router.get("/live", async (req, res) => {
  try {
    const data = await cachedFetch(`${BASE}/live`);
    res.json(data);
  } catch (err) {
    res.status(502).json({ error: "Failed to fetch live events" });
  }
});

module.exports = router;

Why Proxy?

  • Your API key stays on the server — never in client JavaScript
  • You can add your own caching layer (reduces API usage by 80%+)
  • You control rate limiting per user
  • You can transform/filter data before sending to the frontend

Step 3: Backend — Bet Engine

The core business logic — validate bets, deduct balance, store records:
// server/routes/bets.js
const express = require("express");
const router = express.Router();
const db = require("../db"); // Your PostgreSQL pool
const { requireAuth } = require("../middleware/auth");

// POST /api/bets — Place a bet
router.post("/", requireAuth, async (req, res) => {
  const { eventId, eventName, marketId, marketName,
          selectionId, selectionName, odds, stake } = req.body;

  // Validation
  if (!eventId || !marketId || !selectionId || !odds || !stake) {
    return res.status(400).json({ error: "Missing required fields" });
  }
  if (stake < 1 || stake > 10000) {
    return res.status(400).json({ error: "Stake must be between $1 and $10,000" });
  }

  const client = await db.connect();

  try {
    await client.query("BEGIN");

    // Check balance
    const userRow = await client.query(
      "SELECT balance FROM users WHERE id = $1 FOR UPDATE",
      [req.user.id]
    );
    const balance = parseFloat(userRow.rows[0].balance);

    if (balance < stake) {
      await client.query("ROLLBACK");
      return res.status(400).json({ error: "Insufficient balance" });
    }

    const potentialPayout = Math.round(stake * odds * 100) / 100;

    // Deduct balance
    await client.query(
      "UPDATE users SET balance = balance - $1 WHERE id = $2",
      [stake, req.user.id]
    );

    // Create bet record
    const betResult = await client.query(
      `INSERT INTO bets
       (user_id, event_id, event_name, market_id, market_name,
        selection_id, selection_name, odds, stake, potential_payout)
       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
       RETURNING *`,
      [req.user.id, eventId, eventName, marketId, marketName,
       selectionId, selectionName, odds, stake, potentialPayout]
    );

    // Record transaction
    await client.query(
      `INSERT INTO transactions (user_id, type, amount, bet_id)
       VALUES ($1, 'bet', $2, $3)`,
      [req.user.id, -stake, betResult.rows[0].id]
    );

    await client.query("COMMIT");

    res.json({
      bet: betResult.rows[0],
      newBalance: balance - stake,
    });
  } catch (err) {
    await client.query("ROLLBACK");
    res.status(500).json({ error: "Failed to place bet" });
  } finally {
    client.release();
  }
});

// GET /api/bets — User's bet history
router.get("/", requireAuth, async (req, res) => {
  const result = await db.query(
    `SELECT * FROM bets WHERE user_id = $1
     ORDER BY created_at DESC LIMIT 50`,
    [req.user.id]
  );
  res.json({ bets: result.rows });
});

module.exports = router;
Key points:
  • FOR UPDATE locks the user row to prevent race conditions on balance
  • Transaction wraps deduction + bet creation + audit log atomically
  • Validation happens server-side (never trust the frontend)

Step 4: Frontend — Event Browser

Display events grouped by sport with real-time odds:
// src/components/EventList.jsx
import { useState, useEffect } from "react";

export default function EventList({ onSelectEvent }) {
  const [events, setEvents] = useState([]);
  const [sport, setSport] = useState("soccer");
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/events?sport=${sport}`)
      .then((r) => r.json())
      .then((data) => {
        setEvents(data.events || []);
        setLoading(false);
      });
  }, [sport]);

  const sports = [
    { key: "soccer", label: "Soccer" },
    { key: "basketball", label: "Basketball" },
    { key: "tennis", label: "Tennis" },
    { key: "americanfootball", label: "NFL" },
    { key: "esports", label: "Esports" },
  ];

  return (
    <div className="event-list">
      <nav className="sport-tabs">
        {sports.map((s) => (
          <button
            key={s.key}
            className={sport === s.key ? "active" : ""}
            onClick={() => setSport(s.key)}
          >
            {s.label}
          </button>
        ))}
      </nav>

      {loading ? (
        <div className="loading">Loading events...</div>
      ) : (
        <div className="events">
          {events.map((event) => (
            <div
              key={event.id}
              className="event-card"
              onClick={() => onSelectEvent(event)}
            >
              <div className="teams">
                <span>{event.home_team}</span>
                <span className="vs">vs</span>
                <span>{event.away_team}</span>
              </div>
              <div className="meta">
                <span className="league">{event.league}</span>
                <span className="time">
                  {new Date(event.commence_time).toLocaleString()}
                </span>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Step 5: Frontend — Bet Slip

The bet slip is the checkout experience — keep it simple and clear:
// src/components/BetSlip.jsx
import { useState } from "react";

export default function BetSlip({ selection, onClear, onBetPlaced }) {
  const [stake, setStake] = useState("");
  const [placing, setPlacing] = useState(false);
  const [error, setError] = useState("");

  if (!selection) return null;

  const stakeNum = parseFloat(stake) || 0;
  const payout = Math.round(stakeNum * selection.odds * 100) / 100;

  async function placeBet() {
    if (stakeNum < 1) {
      setError("Minimum stake is $1");
      return;
    }

    setPlacing(true);
    setError("");

    try {
      const res = await fetch("/api/bets", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          eventId: selection.eventId,
          eventName: selection.eventName,
          marketId: selection.marketId,
          marketName: selection.marketName,
          selectionId: selection.selectionId,
          selectionName: selection.selectionName,
          odds: selection.odds,
          stake: stakeNum,
        }),
      });

      const data = await res.json();

      if (!res.ok) {
        setError(data.error || "Failed to place bet");
        return;
      }

      onBetPlaced(data);
      onClear();
    } catch {
      setError("Network error. Try again.");
    } finally {
      setPlacing(false);
    }
  }

  return (
    <div className="bet-slip">
      <div className="slip-header">
        <h3>Bet Slip</h3>
        <button onClick={onClear}>Clear</button>
      </div>

      <div className="selection-info">
        <p className="event">{selection.eventName}</p>
        <p className="market">{selection.marketName}</p>
        <p className="pick">
          {selection.selectionName}
          <span className="odds">@ {selection.odds}</span>
        </p>
      </div>

      <div className="stake-input">
        <label>Stake ($)</label>
        <input
          type="number"
          min="1"
          max="10000"
          value={stake}
          onChange={(e) => setStake(e.target.value)}
          placeholder="Enter stake"
        />
      </div>

      {stakeNum > 0 && (
        <div className="payout-preview">
          <span>Potential payout</span>
          <span className="amount">${payout.toFixed(2)}</span>
        </div>
      )}

      {error && <p className="error">{error}</p>}

      <button
        className="place-btn"
        onClick={placeBet}
        disabled={placing || stakeNum < 1}
      >
        {placing ? "Placing..." : `Place Bet — $${stakeNum.toFixed(2)}`}
      </button>
    </div>
  );
}

Step 6: Automatic Settlement (Cron Worker)

This is the part most tutorials skip. Run a cron job every 60 seconds to settle finished bets:
// server/workers/settlement.js
const db = require("../db");

const API_KEY = process.env.FIELDFUNDED_API_KEY;
const BASE = "https://api.fieldfunded.com/v1";

async function settlePendingBets() {
  // Get all pending bets
  const pending = await db.query(
    "SELECT * FROM bets WHERE status = 'pending'"
  );

  if (pending.rows.length === 0) return;

  console.log(`[Settlement] Checking ${pending.rows.length} pending bets`);

  for (const bet of pending.rows) {
    try {
      const res = await fetch(`${BASE}/bets/check`, {
        method: "POST",
        headers: {
          "X-API-Key": API_KEY,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          event_id: bet.event_id,
          market: bet.market_name,
          selection: bet.selection_name,
          stake: parseFloat(bet.stake),
          odds: parseFloat(bet.odds),
          market_id: bet.market_id,
          selection_id: bet.selection_id,
        }),
      });

      const result = await res.json();

      // Skip if event has not finished yet
      if (result.status === "pending") continue;

      // Settle the bet
      const client = await db.connect();
      try {
        await client.query("BEGIN");

        // Update bet status
        await client.query(
          `UPDATE bets SET status = $1, settled_at = NOW()
           WHERE id = $2`,
          [result.status, bet.id]
        );

        // Credit payout if won or refund
        if (result.status === "won" || result.status === "refund") {
          const payout =
            result.status === "won"
              ? parseFloat(bet.potential_payout)
              : parseFloat(bet.stake);

          await client.query(
            "UPDATE users SET balance = balance + $1 WHERE id = $2",
            [payout, bet.user_id]
          );

          await client.query(
            `INSERT INTO transactions (user_id, type, amount, bet_id)
             VALUES ($1, $2, $3, $4)`,
            [bet.user_id, result.status === "won" ? "payout" : "refund",
             payout, bet.id]
          );

          console.log(
            `[Settlement] Bet ${bet.id}: ${result.status} — ` +
            `$${payout} credited to user ${bet.user_id}`
          );
        } else {
          console.log(`[Settlement] Bet ${bet.id}: lost`);
        }

        await client.query("COMMIT");
      } catch (err) {
        await client.query("ROLLBACK");
        console.error(`[Settlement] DB error for bet ${bet.id}:`, err);
      } finally {
        client.release();
      }
    } catch (err) {
      // API error — skip this bet, retry next cycle
      console.error(`[Settlement] API error for bet ${bet.id}:`, err.message);
    }

    // Brief pause between API calls
    await new Promise((r) => setTimeout(r, 200));
  }
}

// Run every 60 seconds
setInterval(settlePendingBets, 60_000);
console.log("[Settlement] Worker started — checking every 60s");

// Initial run
settlePendingBets();
This worker:
  • Polls all pending bets from your database
  • Sends each to the settlement endpoint for resolution
  • Credits the user balance on win or refund (transactionally)
  • Logs every settlement for auditing
  • Handles errors gracefully — failed checks retry on the next cycle
For a deeper dive on the settlement logic and edge cases (partial settlement, dead heats, voided markets), see the Build a Settlement Engine guide.

Step 7: Player Props Display

Player props are the fastest-growing market category — users expect to bet on individual player performance (points, rebounds, assists, goals, cards), not just match outcomes. The odds response already includes player prop markets alongside standard markets:
// server/routes/props.js
router.get("/events/:id/props", async (req, res) => {
  try {
    const data = await cachedFetch(
      `${BASE}/events/${req.params.id}/odds`
    );

    // Filter for player prop markets
    const propKeywords = [
      "player", "goalscorer", "points", "rebounds",
      "assists", "threes", "cards", "shots"
    ];

    const props = (data.markets || []).filter((m) => {
      const name = m.name.toLowerCase();
      return propKeywords.some((kw) => name.includes(kw));
    });

    // Group by player name for cleaner display
    const byPlayer = {};
    for (const market of props) {
      for (const sel of market.selections) {
        const player = sel.name.split(" - ")[0] || sel.name;
        if (!byPlayer[player]) byPlayer[player] = [];
        byPlayer[player].push({
          market: market.name,
          line: sel.name,
          odds: sel.odds,
          market_id: market.id,
          selection_id: sel.id,
        });
      }
    }

    res.json({ players: byPlayer, total: props.length });
  } catch (err) {
    res.status(502).json({ error: "Failed to fetch player props" });
  }
});
A typical NBA game returns 200+ player prop markets. Soccer matches include goalscorer, cards, and shots props. Player props settle automatically through the same settlement worker — no extra code needed. For a dedicated tracker, see the Player Props Tracker guide.

Step 8: Live Scores

Show real-time scores alongside your odds. The scores endpoint is lightweight and optimized for frequent polling:
// server/routes/scores.js
router.get("/scores", async (req, res) => {
  try {
    const sport = req.query.sport || "";
    const data = await cachedFetch(
      `${BASE}/scores${sport ? `?sport=${sport}` : ""}`
    );
    res.json(data);
  } catch (err) {
    res.status(502).json({ error: "Failed to fetch scores" });
  }
});
Scores include period breakdowns — quarters for basketball, halves for soccer and american football, sets for tennis, and map scores for esports (CS2, LoL, Valorant). Poll every 30-60 seconds during live events.

Step 9: Parlay Support

Parlays (accumulators) are the highest-margin product on any sportsbook. Let users combine multiple selections into a single bet:
// server/routes/parlays.js
router.post("/parlays", requireAuth, async (req, res) => {
  const { legs, stake } = req.body;

  if (!legs || legs.length < 2 || legs.length > 15) {
    return res.status(400).json({
      error: "Parlays require 2-15 legs",
    });
  }

  // Calculate combined odds
  const combinedOdds = legs.reduce(
    (acc, leg) => acc * leg.odds, 1
  );
  const potentialPayout = Math.round(stake * combinedOdds * 100) / 100;

  const client = await db.connect();
  try {
    await client.query("BEGIN");

    // Check balance
    const userRow = await client.query(
      "SELECT balance FROM users WHERE id = $1 FOR UPDATE",
      [req.user.id]
    );
    if (parseFloat(userRow.rows[0].balance) < stake) {
      await client.query("ROLLBACK");
      return res.status(400).json({ error: "Insufficient balance" });
    }

    // Deduct + store parlay
    await client.query(
      "UPDATE users SET balance = balance - $1 WHERE id = $2",
      [stake, req.user.id]
    );

    const parlayResult = await client.query(
      `INSERT INTO parlays
       (user_id, legs, combined_odds, stake, potential_payout)
       VALUES ($1, $2, $3, $4, $5) RETURNING *`,
      [req.user.id, JSON.stringify(legs), combinedOdds,
       stake, potentialPayout]
    );

    await client.query("COMMIT");
    res.json({ parlay: parlayResult.rows[0] });
  } catch (err) {
    await client.query("ROLLBACK");
    res.status(500).json({ error: "Failed to place parlay" });
  } finally {
    client.release();
  }
});
Parlay settlement uses the same settlement API — check each leg individually. If any leg loses, the parlay loses. If a leg is refunded (voided match, postponement), that leg is treated as odds 1.00 and the parlay continues with the remaining legs. See the Parlay Calculator guide for the full resolution logic. Let users find specific teams, players, or matchups instantly:
// server/routes/search.js
router.get("/search", async (req, res) => {
  const q = req.query.q;
  if (!q || q.length < 2) {
    return res.status(400).json({ error: "Query too short" });
  }

  try {
    const data = await cachedFetch(
      `${BASE}/events/search?q=${encodeURIComponent(q)}`
    );
    res.json(data);
  } catch (err) {
    res.status(502).json({ error: "Search failed" });
  }
});
Search works across all sports — soccer, basketball, american football, tennis, esports, and more. Users can search by team name (“Arsenal”, “Lakers”), competition (“Champions League”, “NFL”), or even specific matchups (“Arsenal vs Chelsea”).

Step 11: Deployment Checklist

ComponentRecommended hostingCost
React frontendVercel or Cloudflare PagesFree
Node.js backendRailway, Render, or VPS$5-20/mo
PostgreSQLSupabase or NeonFree tier
FieldFunded APIFree tier$0 (10K req/mo)
DomainCloudflare Registrar$10/year
Total cost to launch: under $25/month.

Rate Limit Math

ComponentRequests/dayMonthly total
Event browser (soccer, basketball, NFL, tennis, esports — cached 5s)~1,70051,000
Odds + player props (50 events/day, cached)~50015,000
Live scores (30s poll, 10 concurrent events)~2,88086,400
Settlement worker (100 pending bets, 24x)~2,40072,000
Search (user-initiated, ~200/day)~2006,000
Total~7,680~230,400
The Starter plan at $29/month covers this comfortably. With aggressive caching (5-10s TTL on odds, 30s on scores), you can serve hundreds of concurrent users on a single API tier.

Security Considerations

Before going live, implement these:
  • Hash passwords with bcrypt (never store plaintext)
  • Rate limit your own API endpoints (express-rate-limit)
  • Validate all inputs server-side (never trust frontend data)
  • Use HTTPS everywhere (Cloudflare provides free SSL)
  • Store API keys in environment variables, never in code
  • Add CSRF protection for bet placement
  • Implement responsible gambling limits (daily/weekly deposit caps)
Running a real-money betting platform requires gambling licenses in most jurisdictions. This tutorial covers the technical implementation only. Before accepting real money:
  • Consult a lawyer specializing in gambling regulation
  • Obtain the required licenses for your target markets
  • Implement KYC (Know Your Customer) verification
  • Add responsible gambling tools (self-exclusion, deposit limits)
For play-money or educational platforms, no license is typically required.

Get Your Free API Key

Start building today — 10,000 free requests/month

See Pricing

All plans compared — from free to enterprise