DEV Community

Omar Eldeeb
Omar Eldeeb

Posted on Originally published at datatooly.xyz

FPL Live Gameweek Points from the API: Picks Math

A Fantasy Premier League team's gameweek score is each picked player's total_points from https://fantasy.premierleague.com/api/event/{gw}/live/, multiplied by the pick's multiplier from entry/{manager_id}/event/{gw}/picks/, summed, minus event_transfers_cost. Multipliers are 0 for the bench, 1 for starters, 2 for the captain and 3 for a triple captain. Auto-subs and vice-captaincy change them.

The Python below rebuilds the score from those two endpoints, including automatic substitutions and the captain falling back to the vice-captain. I checked it against FPL's own numbers for 300 team-gameweeks on 2026-10-14 (gameweeks 4 to 6 of 2026/27, 100 managers from four depths of the Overall league): all 300 matched, including 91 automatic substitutions.

The two endpoints

event/{gw}/live/ returns elements, one row per player:

Field Meaning
id Player ID (matches bootstrap-static/ → elements[].id)
stats.total_points Points this gameweek so far
stats.minutes, goals_scored, assists, clean_sheets, bonus, bps, saves, defensive_contribution, expected_goals... The counters behind the points
explain Points broken down per fixture, e.g. {"identifier": "goals_scored", "points": 10, "value": 2}

entry/{manager_id}/event/{gw}/picks/ returns:

Field Meaning
picks 15 rows: element, position (1–11 starters, 12–15 bench in priority order), multiplier, is_captain, is_vice_captain, element_type
active_chip null, bboost, 3xc, freehit or wildcard
automatic_subs [{element_in, element_out, event}] once FPL has processed them
entry_history points (before the hit), event_transfers_cost, total_points, points_on_bench, rank, overall_rank, bank, value

For a gameweek that hasn't started, event/{gw}/live/ returns {"elements":[]} and the picks endpoint returns 404 {"detail":"Not found."}.

The simple version: after a gameweek

Once a gameweek is over, FPL has already applied auto-subs and captaincy to the picks, so the sum is direct:

score = sum(live[p.element].total_points * p.multiplier for p in picks) - event_transfers_cost
Enter fullscreen mode Exit fullscreen mode

For manager 895045 in gameweek 6 that gives 77, and entry_history.points is 77. points_on_bench (6) is the sum for the four multiplier: 0 players.

The full version: projecting the rules yourself

During a gameweek you want the score as it stands, with the substitutions that will happen. This script works from the lineup set at the deadline. For a finished gameweek it first swaps FPL's processed auto-subs back out, which is what makes it testable against FPL's numbers.

import sys
import time

import requests

API = "https://fantasy.premierleague.com/api/"
s = requests.Session()
s.headers["User-Agent"] = "fpl-live-points/1.0 (personal script)"


def get(path, attempts=4):
    for i in range(attempts):
        r = s.get(API + path, timeout=30)
        if r.status_code == 200:
            return r.json()
        if r.status_code == 404:
            raise LookupError(f"{path}: {r.json().get('detail')}")
        time.sleep(2 ** i)  # 429 / 5xx: back off 1, 2, 4 s
    r.raise_for_status()


boot = get("bootstrap-static/")
PLAYER = {p["id"]: p for p in boot["elements"]}
MIN_PLAY = {t["id"]: t["squad_min_play"] for t in boot["element_types"]}  # 1 GKP, 3 DEF, 2 MID, 1 FWD
MAX_PLAY = {t["id"]: t["squad_max_play"] for t in boot["element_types"]}


def deadline_lineup(picks, auto_subs):
    """After a gameweek, FPL's picks already have the auto-subs swapped in.
    Swap them back to get the 15 players in the order the manager set."""
    by_el = {p["element"]: dict(p) for p in picks}
    for sub in auto_subs:
        a, b = by_el[sub["element_in"]], by_el[sub["element_out"]]
        a["position"], b["position"] = b["position"], a["position"]
    return sorted(by_el.values(), key=lambda p: p["position"])


def gameweek_points(manager_id, gw):
    live = {e["id"]: e["stats"] for e in get(f"event/{gw}/live/")["elements"]}
    fixtures = get(f"fixtures/?event={gw}")
    picks = get(f"entry/{manager_id}/event/{gw}/picks/")
    chip = picks["active_chip"]

    # A player "didn't play" only once all of their team's matches this gameweek are over.
    done = {}
    for f in fixtures:
        for team in (f["team_h"], f["team_a"]):
            done[team] = done.get(team, True) and f["finished_provisional"]
    mins = lambda el: live.get(el, {}).get("minutes", 0)
    gw_over = all(f["finished_provisional"] for f in fixtures)  # also covers teams with no match (blank)
    out_for_good = lambda el: mins(el) == 0 and done.get(PLAYER[el]["team"], gw_over)
    pos = lambda el: PLAYER[el]["element_type"]

    order = deadline_lineup(picks["picks"], picks["automatic_subs"])
    xi = [p["element"] for p in order[:11]]
    bench = [p["element"] for p in order[11:]]

    subs = []
    if chip != "bboost":
        for out in list(xi):
            if not out_for_good(out):
                continue
            for cand in bench:  # bench order = substitution priority
                if mins(cand) == 0 or (pos(out) == 1) != (pos(cand) == 1):
                    continue
                trial = [cand if e == out else e for e in xi]
                counts = {t: sum(pos(e) == t for e in trial) for t in MIN_PLAY}
                if all(MIN_PLAY[t] <= counts[t] <= MAX_PLAY[t] for t in MIN_PLAY):
                    xi = trial
                    bench.remove(cand)
                    subs.append((cand, out))
                    break

    mult = {el: 1 for el in xi + (bench if chip == "bboost" else [])}
    cap = next(p["element"] for p in order if p["is_captain"])
    vice = next(p["element"] for p in order if p["is_vice_captain"])
    armband = cap if not out_for_good(cap) else vice if not out_for_good(vice) else None
    if armband in mult:
        mult[armband] = 3 if chip == "3xc" else 2

    gross = sum(live.get(el, {}).get("total_points", 0) * m for el, m in mult.items())
    hit = picks["entry_history"]["event_transfers_cost"]
    return {"chip": chip, "subs": subs, "mult": mult, "live": live, "gross": gross,
            "hit": hit, "net": gross - hit, "fpl_points": picks["entry_history"]["points"]}


if __name__ == "__main__":
    manager_id, gw = int(sys.argv[1]), int(sys.argv[2])
    r = gameweek_points(manager_id, gw)
    for el, m in r["mult"].items():
        tag = {2: " (C)", 3: " (TC)"}.get(m, "")
        print(f"  {PLAYER[el]['web_name']:<14} {r['live'][el]['total_points']:>3} x{m}{tag}")
    for cand, out in r["subs"]:
        print(f"  auto-sub: {PLAYER[cand]['web_name']} in for {PLAYER[out]['web_name']}")
    print(f"chip={r['chip']}  gross={r['gross']}  hit=-{r['hit']}  net={r['net']}  "
          f"(FPL's own gameweek points: {r['fpl_points']})")
Enter fullscreen mode Exit fullscreen mode

python fpl_live_points.py 895045 6 (the Overall league leader on 2026-10-14):

  Raya             3 x1
  Gabriel          2 x1
  De Cuyper       15 x1
  Hall             0 x1
  O'Shea          11 x1
  Rogers           9 x1
  Palmer           8 x2 (C)
  Schade           1 x1
  João Pedro      12 x1
  Haaland          6 x1
  Wissa            2 x1
  auto-sub: O'Shea in for Szoboszlai
chip=None  gross=77  hit=-0  net=77  (FPL's own gameweek points: 77)
Enter fullscreen mode Exit fullscreen mode

Szoboszlai started but played 0 minutes, so the first eligible bench player, O'Shea (11 points), came in. Two more cases from the same run:

$ python fpl_live_points.py 895045 3 | tail -1
chip=3xc  gross=90  hit=-0  net=90  (FPL's own gameweek points: 90)
$ python fpl_live_points.py 2098553 6 | tail -1
chip=None  gross=100  hit=-4  net=96  (FPL's own gameweek points: 100)
Enter fullscreen mode Exit fullscreen mode

The second shows that entry_history.points is the score before the hit: that manager's total_points went from 409 to 505, which is 100 − 4 added.

The rules the script applies

Rule In the code
A starter who plays 0 minutes, once their team's matches are over, is replaced by the first bench player who played out_for_good, bench loop in position order
Goalkeepers only swap with goalkeepers (pos(out) == 1) != (pos(cand) == 1)
The XI must stay a legal formation: at least 1 GK, 3 DEF, 2 MID, 1 FWD squad_min_play / squad_max_play from bootstrap-static/ → element_types
If the captain doesn't play, the vice-captain's points are doubled; if neither plays, nobody's are armband
Triple Captain triples instead of doubles 3 if chip == "3xc"
Bench Boost counts all 15 players and makes no substitutions chip != "bboost"
Transfer hits are taken off the total event_transfers_cost

The Premier League describes the vice-captain rule in its postponements explainer. The formation limits come from the API's own element_types.

One known difference. When two starters are both replaced, the script can pair them with the bench players differently than FPL records in automatic_subs. The same players end up in the XI, so the score is the same. That happened once in the 300 checks. In one Bench Boost gameweek FPL also recorded an entry in automatic_subs (the script records none); with all 15 players counting, the score was the same either way. The other 298 matched FPL's substitutions exactly.

Using it during a live gameweek

All 300 checks were on finished gameweeks. On 2026-10-14 no gameweek was in progress (gameweek 7's deadline is 2026-10-17 10:00 UTC), so the live behaviour below is how the code is designed to work, not something I tested:

  • A starter whose match hasn't kicked off isn't substituted yet, because out_for_good waits for finished_provisional on all of that team's fixtures.
  • A bench player whose match hasn't started can't come on yet (mins(cand) == 0), so the projection can change as matches finish.
  • total_points changes during matches. Bonus points are added later: check event-status/ for "bonus_added": true on each day before treating a score as final.
  • event/{gw}/live/ has no published cache rule. Polling it every minute or two is plenty; matches don't change faster than that.

Pitfalls

  • Points before the hit. Compare like with like: entry_history.points against gross, net against FPL's displayed net score.
  • Free Hit and Wildcard don't change the maths. They change the squad, and the picks endpoint already returns the squad used that gameweek.
  • Double gameweeks. A player with two fixtures has one total_points covering both. explain lists them per fixture.
  • No CORS. The API can't be called from browser JavaScript; run this as a script or on a server.

Related guides for this API

Do it without code

The free FPL query builder builds a ready-to-run input for gameweek picks, mini-league intel, manager dashboards and more, and shows a fixed example of the output shape. It doesn't fetch live points in your browser; the API blocks that.

At scale

The FPL Intelligence actor on Apify has a live_tracker mode that takes a gameweek plus manager or league IDs and returns live points per manager and live standings per league, as a single snapshot or refreshed on an interval. Other modes cover captain and transfer picks, a wildcard optimizer, mini-league intel and manager dashboards. It exports JSON, CSV or Excel. It's free to start, then pay-as-you-go.

Disclosure: I build the datatooly query builder and the Apify actor. The script above needs neither.

Sources: live responses from fantasy.premierleague.com/api/ on 2026-10-14 (FPL publishes no API documentation), and the Premier League's article How postponements affect FPL managers.

Top comments (0)