Guide

Build a Discord bot for NFL injury alerts

Quick answer: Create a Discord channel webhook, then run a Python script from cron that polls /injuries/changes with a Pro key (1 request per poll) or, on the free plan, diffs every player's injury_status from /projections/{week}?position= (4 requests per poll). Post only lineup-moving flips: to Out, off Out, or to Questionable. GridIron checks ESPN's injury report every 2 minutes, so polling the Pro feed every 5 minutes is plenty (about 1,430 requests a week); the fallback polled hourly is about 510 a week, which fits Basic.

Endpoints
/injuries/changes, /projections
Plan
Pro, or Basic fallback
Requests/week
About 510 to 1,430
Discord
Webhook
Watch it run: the bot below on a six-player roster, a few hours after its first snapshot (1:04, with captions). Also on YouTube.

What will this Discord bot do?

Post one line to your league's Discord channel when a player on your watch list changes injury status in a way that moves a lineup: [BENCH] when a playable player goes Out, Doubtful, Inactive or onto a reserve list, [BACK] when one comes off, [WATCH] when someone turns Questionable. Everything else is logged, not pinged. A channel that pings for every practice-squad tight end gets muted by Week 2.

It's one Python file, about 70 lines, that runs from cron. It works on every plan:

  • Pro: it polls /injuries/changes, where the server has already diffed every status for you. 1 request per poll.
  • Free or Basic: it pulls every QB, RB, WR and TE's current injury_status from /projections/{week}?position= and diffs against the snapshot it saved last time. 4 requests per poll.

The bot tries the Pro feed first and falls back on a 403, so the same file keeps working if you upgrade. The deep dive on the changes feed itself (the since cursor, truncation, error handling) is in our game-day injury changes guide; this page is about turning it into a Discord bot.

How do I set up the Discord side?

You don't need a bot application, a gateway connection or a token for this. A channel webhook is enough for one-way alerts:

  1. In Discord, open the channel's settings, then Integrations → Webhooks → New Webhook.
  2. Name it (we use "Injury Desk"), pick the channel, and click Copy Webhook URL.
  3. Store it as the DISCORD_WEBHOOK environment variable next to GRIDIRON_API_KEY. Treat it like a password: anyone with the URL can post to your channel.

A webhook takes a JSON body with a content string of up to 2,000 characters, and Discord renders **bold** markdown in it. That's the whole integration. If you later want slash commands ("what's Puka's status?"), you'll need a real bot user and a library like discord.py; the polling logic below drops straight into its scheduled task.

What does the bot's code look like?

Save it as injury_bot.py. Edit ROSTER (player IDs are First Last#POS) and set NFL_WEEK to the current week for the fallback path. A free key only gets the current week's projections; any other week answers 403 with reason basic_required. Leave DISCORD_WEBHOOK unset to test: it prints instead of posting.

import json, os, pathlib, sys
import requests

API = "https://api.gridirondata.com/api/v1"
KEY = os.environ["GRIDIRON_API_KEY"]
WEBHOOK = os.environ.get("DISCORD_WEBHOOK")          # unset = print only
WEEK = int(os.environ.get("NFL_WEEK", "3"))
STATE = pathlib.Path("injury_state.json")
ROSTER = {"Jaylen Warren#RB", "DJ Moore#WR", "Puka Nacua#WR", "Brock Bowers#TE",
          "Michael Pittman Jr.#WR", "Aaron Jones Sr.#RB"}

OUT = ("OUT", "DOUBTFUL", "INACTIVE")          # statuses come as Out or OUT
def is_out(s):
    s = (s or "").upper()
    return s in OUT or s.startswith(("IR", "PUP", "RESERVE", "NFI", "SUS"))

def get(path, **params):
    return requests.get(f"{API}{path}", headers={"x-api-key": KEY},
                        params=params, timeout=30)

def pro_changes(state):
    """Pro: the server diffs for you. 1 request per poll."""
    params = {"since": state["since"]} if state.get("since") else {}
    r = get("/injuries/changes", **params)
    if r.status_code == 403:
        return None                                   # not Pro: use the fallback
    r.raise_for_status()
    body = r.json()
    state["since"] = body["next_since"]
    return [(c["player_id"], c["player_name"], c["team"], c["previous_status"], c["status"])
            for c in body["changes"]]

def free_changes(state):
    """Free/Basic: diff statuses yourself. 4 requests per poll (QB, RB, WR, TE)."""
    now = {}
    for pos in ("QB", "RB", "WR", "TE"):
        r = get(f"/projections/{WEEK}", position=pos)
        r.raise_for_status()
        for p in r.json()["projections"]:
            now[p["player_id"]] = (p["player_name"], p["team"], p.get("injury_status") or "Healthy")
    before = state.get("statuses", {})
    state["statuses"] = {pid: v[2] for pid, v in now.items()}
    if not before:
        return []                                     # first run: snapshot only
    return [(pid, name, team, before[pid], status)
            for pid, (name, team, status) in now.items()
            if pid in before and before[pid] != status]

def message(pid, name, team, prev, new):
    if is_out(new) and not is_out(prev):
        tag = "[BENCH]"
    elif is_out(prev) and not is_out(new):
        tag = "[BACK]"
    elif (new or "").upper() == "QUESTIONABLE":
        tag = "[WATCH]"
    else:
        return None                                   # not worth a ping
    return f"{tag} **{name}** ({team}): {prev} โ†’ {new}"

state = json.loads(STATE.read_text()) if STATE.exists() else {}
changes = pro_changes(state)
if changes is None:
    changes = free_changes(state)
lines = [m for c in changes if c[0] in ROSTER and (m := message(*c))]
for line in lines:
    print(line)
if WEBHOOK and lines:
    requests.post(WEBHOOK, json={"content": "\n".join(lines)[:2000]}, timeout=10).raise_for_status()
STATE.write_text(json.dumps(state))                   # save only after alerting
print(f"{len(changes)} league-wide changes, {len(lines)} alert(s)", file=sys.stderr)

Three details are doing the real work:

  • State is saved last. The cursor (Pro) or the status snapshot (fallback) is written only after the alert goes out. If Discord is down or the script crashes mid-run, the next poll replays the change instead of dropping it.
  • The first fallback run is silent. With no snapshot there's nothing to diff, so it records all 599-or-so statuses and posts nothing. Run it once by hand before you schedule it, so the first scheduled poll has a baseline.
  • Out-to-Doubtful is not news. Both are "don't start him", so message() returns nothing for it. Tune is_out if your league treats Doubtful differently.

What do the alerts look like?

Here's the fallback path in a test on September 26, 2026 (Week 3 of the 2026 season), after we edited the saved snapshot to pretend DJ Moore and Brock Bowers had been Healthy the day before. Both were listed Questionable in the API at the time:

[WATCH] **DJ Moore** (BUF): Healthy โ†’ Questionable
[WATCH] **Brock Bowers** (LV): Healthy โ†’ Questionable
3 league-wide changes, 2 alert(s)

The third change, Puka Nacua going from Out to Doubtful, was on the roster too, but it's an out-to-out move, so the bot kept quiet. That's the filter working.

When should the bot poll?

GridIron checks ESPN's league-wide injury report every 2 minutes from 8:00 am to 1:00 am ET, and every 10 minutes overnight. When ESPN updates a player, the new status is in the API (and in /injuries/changes) within a few minutes, any day of the week: a Wednesday practice report, a Friday IR move or Sunday inactives. A few scheduled jobs back that up:

What updates injury status (Eastern time)
WhenWhat runsCatches
Every 2 minutes, 8:00 am–1:00 am (every 10 overnight)ESPN injury report checkPractice reports, designations, IR moves, inactives
Every day, 11:45 amOffensive line roster sync (Pro)New linemen, trades, linemen cleared to play
Every day, 12:30 pmSleeper cross-checkPlayers ruled out on Sleeper before ESPN

So there's no refresh time to aim at. Pick an interval that fits your plan. On Pro, every 5 minutes is plenty: the feed is only checked every 2 minutes, so polling faster spends requests without getting news sooner. Overnight changes aren't lost either; the since cursor picks them up on the first poll after 8:00 am. On the fallback, poll hourly, with extra polls on Sunday around inactives, because each poll costs 4 requests:

CRON_TZ=America/New_York
# Pro: every 5 minutes, 8:00 am to 12:55 am
*/5 0,8-23 * * *         python injury_bot.py

# Free/Basic fallback instead: hourly, plus half-hours on Sunday
0 0,8-23 * * *           python injury_bot.py
30 11-19 * * SUN         python injury_bot.py

Use one block or the other, not both. CRON_TZ keeps the schedule on Eastern time, so it doesn't drift an hour when the clocks change in November. On a host without CRON_TZ, write the times in UTC and adjust them at the clock change.

If your bot already runs as a web service with a public HTTPS address, Pro has one more option: register an injury change webhook and GridIron POSTs each change to you within a few minutes of the same checks, with no polling and no requests counted. For a script on cron, polling is simpler.

How many requests does it use?

Requests with the schedules above
SchedulePolls per weekRequests per weekPer month (about)
Pro, every 5 minutes (1 per poll)1,4281,4286,100 of 50,000
Fallback, hourly plus Sunday half-hours (4 per poll)1285122,200 of 5,000

The Pro schedule is 204 polls a day, about 12% of Pro's 50,000 requests a month. The fallback is 17 polls a day plus 9 more on Sunday, about 44% of Basic's 5,000. Polling the fallback every 15 minutes would be about 8,200 requests a month, more than Basic allows, which is why it stays hourly. The free plan's 50 requests a month cover about a dozen polls, so use it to build and test the bot, then run it through the season on Basic or Pro. Beyond the budget, the difference is the work: on Pro the server hands you the diff with a detected_at time and a cursor that never double-counts; on the fallback you're comparing snapshots yourself, and a change that flips and flips back between two polls is invisible.

How can I make the alerts smarter?

  • Name the next man up. When a player goes to [BENCH], one /snaps/{player_id} call (Basic and above) per teammate at the position shows who took the snaps the last time. Our Week 3 usage report has the example: with Michael Pittman Jr. out in Week 2, Germie Bernard went from 4% to 79% of Pittsburgh's snaps.
  • One channel per league. Keep a ROSTER set and a webhook per league in a small JSON file and loop over them. The API calls don't change; only the filtering does.
  • Mention the manager. Discord webhooks can ping a user with <@user_id> in the content. Map each rostered player to his manager's ID and the [BENCH] alert finds the right person.

Frequently asked questions

Do I need a Discord bot token?

No. A channel webhook is enough for one-way alerts. Create one under the channel's Integrations settings and POST a JSON body with a content field. A bot token is only needed for interactive features like slash commands.

Does the bot work on the free plan?

For building and testing, yes. Without a Pro key, /injuries/changes returns 403 and the bot falls back to diffing injury_status from /projections/{week} for QB, RB, WR and TE (the current week, which is on the free plan), which is 4 requests per poll. That's enough to build and test the bot, but a single day of hourly polls (68 requests) is more than the free plan's 50 a month, so run it for the season on Basic or Pro.

How quickly does an injury change reach Discord?

Within a few minutes of ESPN updating the player. GridIron checks ESPN's injury report every 2 minutes from 8:00 am to 1:00 am ET (every 10 minutes overnight), so on Pro, polling the changes feed every 5 minutes puts the alert in Discord within about 5 to 10 minutes. The hourly fallback can take up to an hour.

Will the bot miss changes if my server is down?

On Pro, no: the since cursor picks up every change since the last successful poll, up to 14 days back. On the fallback, a status that changed and changed back while the bot was down is not visible.

Can I run it for several leagues?

Yes. Keep a roster set and webhook per league and loop over them. The API calls are the same for every league, so extra leagues cost no extra requests.