Fantasy

Fantasy

The Fantasy API turns a RaceHooks race weekend into fantasy points. It gives you an estimated points breakdown per driver for any completed Grand Prix or Sprint, a running score while a session is on track, and a pit-lane leaderboard. All of it is keyed to the same canonical driver identity (TLA and team) as every other RaceHooks surface. Pair it with the drop-in fantasy scorer and you have a live leaderboard from race-event webhooks.

Base URLhttps://api.racehooks.io/v1
ℹ
All Fantasy endpoints require a paid plan: Developer tier or higher. A missing, invalid or expired API key returns 401 (code: "unauthorized"); a valid key on the Free tier returns 403 (code: "forbidden", "requires the Developer plan or higher"). A season with no scoring ruleset returns 400 SEASON_NOT_SUPPORTED; today only 2026 is scored. See plans & limits for the full tier matrix, and the pricing page to upgrade.

What you get

Four endpoints, all authenticated with a Bearer token and versioned under /v1:

FieldTypeDescription
races/:raceId/scoresGET · Developer+Estimated fantasy points per driver for a completed race. Grand Prix by default; ?session=sprint scores the Sprint. Returns a full per-driver breakdown.
session/:sessionId/scoresGET · Developer+Running (provisional) fantasy score for each driver while a session is live, computed from the current running order and retirements.
pricesGET · Developer+RaceHooks-generated driver and constructor prices after a round (?season=2026&round=N). A season with no scoring ruleset returns 400 SEASON_NOT_SUPPORTED. Documented in the API reference.
session/:sessionId/pit-timesGET · Developer+Pit-lane time leaderboard for a session, ranked fastest → slowest, with the fastest stop called out.

The scoring model

The race and session endpoints return RaceHooks' own estimated fantasy model, a points system computed with the official F1 Fantasy 2026 rules: finishing position, qualifying, positions gained and lost, fastest lap, and retirements. Every entry ships the full breakdown so you can show your users exactly where each point came from, or re-map the components onto your own league's rules. (To score against official-style, DFS-style, or fully custom rule sets instead, use the fantasy scorer further down.)

Grand Prix finishing points

FinishPoints
P125
P218
P315
P412
P510
P68
P76
P84
P92
P101
P11+0

Sprint finishing points

A Sprint scores a compressed P1 to P8 table. Qualifying does not apply to a Sprint. Under the rules a Sprint fastest lap is worth +5, but it is not currently served: fastestLapPoints comes back null on a Sprint.

FinishPoints
P18
P27
P36
P45
P54
P63
P72
P81
P9+0

Qualifying points

Grand Prix scoring adds qualifying position points (P1 = 10 down to P10 = 1, −1 per place). The game has no driver Q2/Q3 bonus; the constructor qualifying bonus is returned in the race response under constructors (both cars in Q3 +10, one +5; both in Q2 +3, one +1; neither −1):

FieldTypeDescription
qualiPosPointsP1 to P1010 points for pole, decreasing by one per place to 1 point at P10. P11+ scores 0.

Bonuses & penalties

FieldTypeDescription
positionsGainedPoints+1 / placeOne point for every position gained between the starting grid and the finish.
positionsLostPoints−1 / placeOne point deducted for every position lost (a Sprint loss is capped at −10).
fastestLapPoints+10Awarded to the driver with the fastest race lap (a Sprint fastest lap is worth +5 under the rules, not currently served).
dnfPenalty−20 / −10Applied to a retirement or disqualification: −20 in the Grand Prix, −10 in a Sprint.

Reading the breakdown object

Every driver entry carries a breakdown with the same shape on both the race and live endpoints. A component is null when it does not apply (Sprint, or a live session before the flag) and 0 when it applies but scored nothing.

FieldTypeDescription
racePosPointsintegerFinishing-position points from the table (Grand Prix or Sprint scale).
qualiPosPointsinteger | nullQualifying-position points. null when no qualifying result is available, or on a Sprint / live session.
q3Bonusinteger | nullAlways 0: the game has no driver Q2/Q3 bonus (the constructor bonus is under constructors). null when qualifying data is unavailable.
positionsGainedinteger | nullRaw places gained (grid - finish); negative when places were lost. null when grid or finish is unknown.
positionsGainedStatusstringWhy positionsGained is what it is: scored = classified with a grid slot; not-classified = DNF/DNS/DSQ (the game scores -20/-25 instead of places, so positionsGained is null by rule); no-grid = grid unknown.
qualiStatusstring | nulltimed = set a qualifying time; no-time and disqualified both score the game's -5 in qualiPosPoints; null when no qualifying data.
positionsGainedPointsintegerPoints for places gained: +1 per place (gains only).
positionsLostPointsintegerPoints deducted for places lost: -1 per place, 0 when none were lost (a Sprint loss is capped at -10).
fastestLapPointsinteger | null+10 if this driver set the fastest race lap, else 0. null on a Sprint without captured fastest-lap timing, and on a live session.
overtakesinteger | nullOn-track overtakes credited to the driver in the session, counted from the stored timing-data overtake events. null when no overtake record exists for the session.
overtakePointsinteger | nullPoints for overtakes: +1 per overtake (overtakes x 1). null when overtakes is null.
driverOfTheDayPointsintegerDriver of the Day bonus (+10 for the winner of the fan vote), else 0. Always 0 on a Sprint.
beatTeammateRaceintegerDeprecated: always 0. The game awards no beat-teammate points.
beatTeammateQualiinteger | nullDeprecated: always 0 (null on a Sprint / live session). The game awards no beat-teammate points.
dnfPenaltyinteger-20 on a Grand Prix DNF / DSQ, -10 in a Sprint, else 0.
totalintegerSum of every component above: the driver's fantasy score for the session.

The race response also carries a constructors array (one entry per team) and, on a Grand Prix, pitSource (dhl or live-timing-approximate). A Sprint response carries constructors too: constructors score in a Sprint under the same rules, so each entry holds the team's Sprint points with qualifyingBonus and pitPoints null. A Sprint has no pitSource.

FieldTypeDescription
constructorIdstringCanonical team slug: the constructor `id` in GET /v1/fantasy/prices.
assetIdstringSame as constructorId.
teamstringConstructor display name.
qualifyingBonusinteger | nullConstructor qualifying bonus (both cars in Q3 +10, one +5; else both in Q2 +3, one +1, neither -1). null on a Sprint and when qualifying data is unavailable.
pitPointsinteger | nullConstructor pit-stop points from the DHL award times; approximate until the round's DHL table is published (see pitSource). null on a Sprint.
pointsnumberThe constructor's whole-weekend points for the session scored: both drivers' points under the constructor rules, plus qualifyingBonus and pitPoints on a Grand Prix. A Sprint response carries the Sprint's constructor points.
excludedFromDriversobjectTerms in the drivers' totals that the game does not give a constructor: driverOfTheDayPoints (Driver of the Day is never a constructor score) and qualifyingDisqualificationExtra (a qualifying disqualification costs the constructor -10 against the driver's -5, so -5 here).

Race scores

GET/v1/fantasy/races/:raceId/scoresDEVELOPER+

Estimated fantasy points for a completed session, sorted highest total first. Pass ?session=sprint to score the Sprint instead of the Grand Prix; the response sessionType (grand_prix or sprint) echoes which was scored. If a race has no qualifying data yet, qualiPosPoints and q3Bonus come back null and a dataQuality note is included.

FieldTypeDescription
raceIdstring (path)Race id, e.g. 2026-monza-r13. Take it from events[].raceId in GET /v1/events (slugs are not guessable; the Italian GP is 2026-monza-r13, not 2026-italy-r16). A race of an unsupported season returns 400 SEASON_NOT_SUPPORTED.
sessionstring (query)"race" (default) scores the Grand Prix; "sprint" scores the Sprint using the compressed points table.

Unknown race, no sprint, not scored yet

Three different situations get three different answers, so you can tell a wrong id from a race that has not happened yet:

FieldTypeDescription
404 RACE_NOT_FOUNDerrorThe id matches no race (a slug you guessed, such as 2026-japan_r, rather than events[].raceId). Take the id from GET /v1/events.
404 SPRINT_NOT_AT_ROUNDerror?session=sprint on a round that has no sprint (only sprint weekends do).
200, scores: []resultA real race with no results yet: scores is empty and dataQuality says so. Retry after the session.

The same envelope applies to session ids: /session/:sessionId/scores and /session/:sessionId/pit-times return 404 SESSION_NOT_FOUND for an id that matches no session (take it from events[].sessions[].sessionId). A real session that is not live, or has no stops yet, returns 200 with an empty list.

bash
# Find the race id first: it is events[].raceId from GET /v1/events (slugs are not guessable)
curl "https://api.racehooks.io/v1/events?season=2026" \
  -H "Authorization: Bearer ${TOKEN}"      # -> "raceId": "2026-monza-r13"

# Grand Prix scoring (default)
curl "https://api.racehooks.io/v1/fantasy/races/2026-monza-r13/scores" \
  -H "Authorization: Bearer ${TOKEN}"

# Sprint scoring: compressed points table (sprint weekends only, e.g. 2026-miami-r4)
curl "https://api.racehooks.io/v1/fantasy/races/2026-miami-r4/scores?session=sprint" \
  -H "Authorization: Bearer ${TOKEN}"

# Response (abridged to one driver; values illustrative, field set is the live schema)
{
  "data": {
    "raceId": "2026-monza-r13",
    "sessionType": "grand_prix",
    "scores": [
      {
        "driver": "leclerc-charles",
        "tla": "LEC",
        "team": "Ferrari",
        "breakdown": {
          "racePosPoints": 25,
          "qualiPosPoints": 10,
          "q3Bonus": null,
          "positionsGained": 0,
          "positionsGainedPoints": 0,
          "positionsLostPoints": 0,
          "fastestLapPoints": 10,
          "overtakes": 3,
          "overtakePoints": 3,
          "driverOfTheDayPoints": 0,
          "beatTeammateRace": 0,
          "beatTeammateQuali": 0,
          "dnfPenalty": 0,
          "total": 51
        }
      }
    ],
    "constructors": [
      { "team": "Ferrari", "qualifyingBonus": 0, "pitPoints": 4, "points": 61 }
    ],
    "pitSource": "dhl"
  }
}

# An id that is not a race (404), and a sprint asked of a round with none (404)
{ "error": "Unknown race id 2026-japan_r. Race ids come from GET /v1/events (events[].raceId); they are not guessable slugs.", "code": "RACE_NOT_FOUND", "statusCode": 404 }
{ "error": "Race 2026-monza-r13 has no sprint. Only sprint weekends can be scored with ?session=sprint.", "code": "SPRINT_NOT_AT_ROUND", "statusCode": 404 }

# A season with no scoring ruleset is refused (400), not scored with another season's rules
curl "https://api.racehooks.io/v1/fantasy/races/2019-monza-r14/scores" -H "Authorization: Bearer ${TOKEN}"
{ "error": "No fantasy scores for season 2019. Supported: 2026.", "code": "SEASON_NOT_SUPPORTED", "statusCode": 400 }

Live in-session scores

GET/v1/fantasy/session/:sessionId/scoresDEVELOPER+

A running, provisional score for each driver while a session is on track, derived from the live running order and retirements. Position, teammate and DNF components update lap by lap; the grid-derived (positions gained, qualifying) and fastest-lap components are only finalised once the session ends; until then they are null. For the final breakdown, call the race scores endpoint after the flag.

FieldTypeDescription
sessionIdstring (path)Session id, e.g. 2026-italy_r or 2026-italy_s.
livebooleantrue while a session is genuinely live for this id; false (with an empty scores list and a message) otherwise.
provisionalbooleanAlways true for this endpoint. Scores are in-flight and will change.
currentLapintegerCurrent lap of the session.
totalLapsintegerScheduled total laps.
bash
# Running score while a session is on track
curl "https://api.racehooks.io/v1/fantasy/session/2026-italy_r/scores" \
  -H "Authorization: Bearer ${TOKEN}"

# Response: provisional; grid-derived + fastest-lap components stay null until the flag
{
  "data": {
    "sessionId": "2026-italy_r",
    "live": true,
    "provisional": true,
    "sessionType": "grand_prix",
    "currentLap": 34,
    "totalLaps": 53,
    "scores": [
      {
        "driver": "LEC", "tla": "LEC", "team": "Ferrari",
        "breakdown": {
          "racePosPoints": 25, "beatTeammateRace": 0, "dnfPenalty": 0,
          "qualiPosPoints": null, "q3Bonus": null,
          "positionsGained": null, "positionsGainedPoints": 0,
          "fastestLapPoints": null, "beatTeammateQuali": null,
          "total": 28
        }
      }
    ]
  }
}

# When nothing is live for this id:
# { "data": { ..., "live": false, "scores": [] },
#   "message": "No live session is currently active for this id. ..." }

Push: the fantasy.scores feed

Feed ID
fantasy.scoreslive
Cadence: on scoring change · Race and Sprint sessions · available on Developer tier and above

The same provisional scores as the endpoint above, pushed to your webhook or SSE stream instead of polled. Both come from one scoring function, so a pushed frame and a polled response never disagree about a driver's points.

It emits on scoring change, not on a timer. A lap counter ticking over is not by itself a delivery. A position swap, a driver leading their teammate, or a retirement is. That keeps a race in the low hundreds of deliveries rather than the ~2,700 an every-tick feed would bill you for. Fires during Race and Sprint sessions only: practice and qualifying have no finishing-position table.

FieldTypeDescription
feedstringAlways "fantasy.scores".
sessionIdstringThe session these scores belong to. This is the same id you would pass to the REST routes above.
utcstringWhen the running order behind these scores was observed (ISO-8601), not when the frame was serialised.
livebooleanAlways true. The feed only fires during a running session.
provisionalbooleanAlways true. Grid-derived and fastest-lap components stay null until the flag; call the race scores endpoint after the session for the final breakdown.
sessionTypestring"grand_prix" or "sprint". A sprint scores on the compressed points table.
scoresarrayPer-driver entries, sorted by total descending. Each carries a full driver reference and the same breakdown object the REST routes return.
scores[].driverobjectFull driver reference: driverId, constructorId, number, tla, name, team.
ℹ
Do not assume deliveries arrive in order. Webhook deliveries are sent concurrently and retried, so two frames can land inverted (about 2 in 57 in a measured run). Order by the payload's utc (and currentLap), keep the newest frame per session, and ignore an older one that arrives late.
bash
# Subscribe your endpoint to the feed
curl -X POST "https://api.racehooks.io/v1/webhooks" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{ "webhookUrl": "https://your-app.com/hook", "feedId": "fantasy.scores" }'

# ...or read it on the SSE stream
curl -N "https://api.racehooks.io/v1/stream?feeds=fantasy.scores" \
  -H "Authorization: Bearer ${TOKEN}"

# Delivered payload: fires when a score moves, not every tick
{
  "feed": "fantasy.scores",
  "sessionId": "2026-italy_r",
  "utc": "2026-09-06T13:41:02.118Z",
  "live": true,
  "provisional": true,
  "sessionType": "grand_prix",
  "currentLap": 34,
  "totalLaps": 53,
  "scores": [
    {
      "driver": {
        "driverId": "leclerc-charles", "constructorId": "ferrari", "number": "16",
        "tla": "LEC", "name": "Charles Leclerc", "team": "Scuderia Ferrari"
      },
      "breakdown": {
        "racePosPoints": 25, "beatTeammateRace": 0, "dnfPenalty": 0,
        "qualiPosPoints": null, "q3Bonus": null,
        "positionsGained": null, "positionsGainedPoints": 0,
        "fastestLapPoints": null, "beatTeammateQuali": null,
        "total": 28
      }
    }
  ]
}

Pit-stop time leaderboard

GET/v1/fantasy/session/:sessionId/pit-timesDEVELOPER+

Stationary pit-stop times for a session (the seconds the car is stopped in its box), ranked fastest to slowest, with the single fastest stop broken out as fastestStop. Stationary pit-stop time is the constructor scoring category in most fantasy games, so feed it straight into a team leaderboard. This is the same pit picture surfaced by the strategy & tyre feeds.

FieldTypeDescription
sessionIdstring (path)Session id, e.g. 2026-italy_r.
source"live" | "historical"Where the ranking came from: an in-progress session vs. the completed record.
fastestStopobject | nullThe fastest single stop of the session (same shape as a stops[] entry), or null when none is recorded.
stops[].pitStopTimeSecnumberStationary pit-stop time in seconds (car stopped in its box). stops[] also carries driver (id slug), tla, team, and lap.
bash
curl "https://api.racehooks.io/v1/fantasy/session/2026-italy_r/pit-times" \
  -H "Authorization: Bearer ${TOKEN}"

# Response: ranked fastest → slowest, plus the fastest stop of the session
{
  "data": {
    "sessionId": "2026-italy_r",
    "source": "historical",
    "fastestStop": { "driver": "norris-lando", "tla": "NOR", "team": "McLaren", "pitStopTimeSec": 2.104, "lap": 21 },
    "stops": [
      { "driver": "norris-lando", "tla": "NOR", "team": "McLaren", "pitStopTimeSec": 2.104, "lap": 21 },
      { "driver": "verstappen-max", "tla": "VER", "team": "Red Bull Racing", "pitStopTimeSec": 2.418, "lap": 19 }
    ]
  }
}

Build a fantasy app

For a live leaderboard driven by your own scoring rules, subscribe to the events.race feed and pipe its payloads into the open-source @racehooks/f1-fantasy-scorer package (installed from its GitHub repository). The scorer is zero-dependency, fully typed, and deterministic. Every point is logged with a reason code. It ships official-style, DFS-style, and custom rule sets, plus a budget-aware roster optimizer.

GitHub repository →

1. Install

bash
npm install racehooks github:racehooks/racehooks-f1-fantasy-scorer

The scorer installs straight from GitHub as @racehooks/f1-fantasy-scorer and builds itself on install. It is not on the npm registry, so npm install @racehooks/f1-fantasy-scorer will not resolve.

2. Subscribe a roster and wire the scorer

Create one events.race webhook filtered to your league's drivers, and point a FantasyScorer at the same roster:

setup.ts
import { RaceHooks } from "racehooks";
import { FantasyScorer, OfficialF1ScoringRules } from "@racehooks/f1-fantasy-scorer";

const rh = new RaceHooks({
  clientId:     process.env.RACEHOOKS_CLIENT_ID!,
  clientSecret: process.env.RACEHOOKS_CLIENT_SECRET!,
});

// 1. Subscribe to the race-events feed, filtered to your league's roster.
const { webhook, webhookSecret } = await rh.webhooks.create({
  feedId:     "events.race",
  webhookUrl: "https://your-app.com/fantasy-hook",
  filters:    { drivers: ["VER", "NOR", "LEC", "HAM", "RUS"] },
});
console.log("Save this signing secret:", webhookSecret);

// 2. Spin up a scorer for the same roster.
const scorer = new FantasyScorer({
  rules:  OfficialF1ScoringRules,
  roster: ["VER", "NOR", "LEC", "HAM", "RUS"],
});

// 3. Push a fresh leaderboard whenever a point changes.
scorer.on("scoreUpdate", (scores, event) => {
  console.log(`${event.driver} ${event.points > 0 ? "+" : ""}${event.points} (${event.reason})`);
  renderLeaderboard(scores); // { VER: 35, NOR: 39, LEC: 32, ... }
});

3. Score deliveries as they arrive

Verify each delivery's signature, then hand the payload to scorer.ingest(). It routes each event to the right handler and finalises the classification automatically when the session completes:

server.ts
import express from "express";
import { verifySignature } from "racehooks";

const app = express();
app.use("/fantasy-hook", express.raw({ type: "application/json" }));

app.post("/fantasy-hook", (req, res) => {
  const sig = req.headers["x-racehooks-signature"] as string;
  if (!verifySignature(req.body, sig, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).send("Bad signature");
  }

  // ingest() routes any RaceHooks payload to the right handler and
  // auto-finalises the classification when the session completes.
  scorer.ingest(JSON.parse(req.body.toString()));
  res.sendStatus(200);
});
✦
Want to test before race day? Replay a current-season session through your webhook with Simulate. Your scorer receives the exact events.race payloads it would in a live race, so you can validate a full weekend of scoring against a known result on a quiet afternoon.

How race events map to scoring

The scorer draws its points from these events.race events. Two of them are inputs the scorer accepts but RaceHooks does not emit. They are marked below, and the scorer works without them (see events.ts in the scorer repository for the same note).

FieldTypeDescription
driver_finishedevent (not emitted)Finishing-position points, plus positions gained or lost against the starting grid. RaceHooks emits no canonical driver_finished event; the scorer derives the final classification from the last timing positions when session.complete fires, so this is only needed if you synthesise one upstream.
session.completeeventTriggers final classification from the last observed order (the scorer's finalize step).
overtakeeventOvertake points (handles cumulative overtake counts).
fastest.lapeventFastest-lap bonus, for rule sets that award one.
pit.stop.completeeventConstructor pit-stop time points, plus fastest-stop and record-stop bonuses.
retirementeventDNF penalty, race vs. sprint aware.
driver_of_the_dayevent (not emitted)Driver of the Day bonus. Not a live RaceHooks event: Driver of the Day is a post-race fan vote, added after the session rather than emitted on events.race. Supply it yourself if your rule set awards it.
ℹ
RaceHooks is independent and is not affiliated with, endorsed by, or sponsored by Formula 1, the FIA, FOM, or any official fantasy game. Fantasy points are RaceHooks' own estimates and the scorer's rule sets are provided for you to configure. They are not data from, or a service of, Formula 1, the FIA or any fantasy game, and are not an official scoring service.
← Race Events feedrh.fantasy SDK →