Skip to content

RaidKit · Companion

Raid Ping

Automatic Discord reminders for the raiders who still haven't answered your Raid-Helper signup.

CompanionAny WoW version — it runs on Discord, not in the game2026-09-01Live For raid leaders and officers who run signups through the Raid-Helper Discord bot and are tired of chasing the same people every week

One person: the raid leader or an officer with a GitHub account · On GitHub Actions in the cloud; nobody's PC needs to stay on · Nothing. Raiders just answer the Raid-Helper signup in Discord as usualLive and running unattended for one guild since 2026-08-06, with 50 offline checks green; the repository is private because it holds Discord ids and a roster, so there is no public download yet.

Overview

Every raid leader knows the ritual. The Raid-Helper event goes up on Monday, half the roster signs, and by Wednesday you are messaging the same six people to ask whether they are coming. Raid Ping does the chasing for you. It is a small Python script that runs on GitHub's servers, fired once an hour by an external scheduler, so it keeps working while your computer is off.

Each run asks the Raid-Helper API for your Discord server's upcoming events, finds each team's next event in that team's own signup channel, and compares the signups against a roster you keep in a JSON file. At each rung of a reminder ladder (the live setup uses 120 hours, 72 hours, and 24 hours before raid start) it posts one message to your Discord channel through a webhook, @mentioning exactly the people who have not responded.

Responded means responded in any way. Someone who clicked Absence, Tentative, Bench, or Late has answered and will not be pinged. Raid Ping chases silence, not attendance. Each tier recomputes who is still silent at the moment it fires, so signing up after the first nudge ends the nagging for that person.

Raid-Helper is a separate Discord bot by its own authors; Raid Ping is an unofficial client of its public API. If your guild also runs a loot ledger with Gargul Persistence Bonus (an unofficial companion to the Gargul loot addon, and a sibling project that shares only the guild's policy with Raid Ping), the 24-hour reminder is where you can state the consequence for late signups, the way the live guild does with its no-PB line (PB = persistence bonus, the loot-priority points a raider earns by showing up).

What it does, in detail

What one run does

An external scheduler POSTs to GitHub's workflow-dispatch API once an hour. GitHub Actions checks out your private repository and runs nag.py. The first log line reads Run at <UTC time> | dry_run=... ignore_window=..., so you can always tell what mode a run was in. The script fetches your server's posted events from Raid-Helper, logs Server events endpoint that worked: <url> and Fetched N posted event(s) for server <id>, then handles each team in config.json in turn: it keeps only the events posted in that team's signup channel, drops anything that already started, sorts what is left by start time, and looks at the nearest one first.

For that event it works out how many hours remain, decides which reminder tier (if any) is due and not yet fired, fetches the event's full signup list, and diffs it against the roster. Anyone whose Discord user id appears in the signups is excluded. What remains is the mention list. The message is built from one of the tier's templates and posted to the team's webhook, and state.json records that the tier fired. The workflow then commits state.json back to the repository as the user rh-nag-bot; the workflow file grants itself contents: write for that one purpose.

The workflow derives the DRY_RUN and IGNORE_WINDOW environment variables from the manual inputs only when the run was started by workflow_dispatch; a run from GitHub's backup cron always gets false for both, so backup runs are always live. Runs share a concurrency group named rh-nag with cancel-in-progress off, which means overlapping triggers queue behind each other rather than running side by side or being cancelled.

The reminder ladder and collapse

A team's reminders list is a ladder of tiers, each with an hours_before number and one or more message templates. The live configuration uses three: 120 hours (a heads-up that signups are open), 72 hours (a friendly reminder), and 24 hours (the urgent one, which in the live guild carries the no-PB consequence for late signups). On each run only the most urgent tier that is due and has not fired yet is sent; a tier fires at most once per event, even if the job runs late or twice.

If Raid-Helper posts an event only 60 hours out, the 120-hour tier can never fire on time. Raid Ping does not send a burst of stacked pings to catch up. When an event is first seen inside a smaller window, every larger tier that is already in the past is marked done alongside the one that fires. The docs call this collapse: a late-posted event gets exactly one ping now and the remaining tiers later, never three at once.

Silence, not attendance

The roster diff only asks one question: has this person responded at all. Raid-Helper's Absence, Tentative, Bench, and Late buttons all count as a response, the same as signing with a class. The script never reads the kind of response, so it cannot nag someone for declining. What it removes is the guesswork of who has simply not looked at the event.

Because the mention list is rebuilt from live signups at every tier, a raider who answers after the 120-hour ping is gone from the 72-hour and 24-hour lists automatically. Nobody has to edit anything to stop the reminders for one person.

Teams, channels, and shared rosters

Each entry in teams watches one Discord channel id. Only Raid-Helper events posted in that channel count for that team, which is the main safeguard that keeps one team's events from pinging another team's roster. An optional title_contains filter narrows further by a case-insensitive substring of the event title; the live setup leaves it empty.

Rosters live in a top-level rosters table keyed by name, and a team points at one by name. The live guild has two team entries (a main night and a second night, each with its own signup channel and its own webhook) sharing one 25-member roster, so a roster edit happens in one place. An inline roster list on a team still works. The repository also ships a reference spreadsheet at docs/reference/discord_roster.xlsx with a name, username, and id column, the id column pre-formatted as Text so Excel cannot round the ids.

Safety rails

Manual runs from the GitHub Actions tab default to dry_run true and ignore_window true, so clicking Run workflow without changing anything is a preview: the log shows the event, the tier, how many responded, the names it would mention, and the exact message, and nothing is posted and no state is written. The dry run also doubles as the validator for hand-edited JSON, because a syntax error fails before any network call.

Every webhook payload sets Discord's allowed_mentions to users only, so the script structurally cannot @everyone, @here, or ping a role no matter what a template says. Credentials live only in GitHub repository secrets, never in config, code, or logs.

One team's problem does not stop the others, through three separate mechanisms. A team whose roster name is not in the rosters table is skipped with a logged line before any processing starts. A team with no webhook URL in its environment variable, a channel_id still set to a PASTE placeholder, or an empty roster is logged and skipped inside processing. And each team's processing runs inside its own error handler, so an exception such as a failed webhook POST or an API error on one team is logged as an error line and the remaining teams still run. A server_id left as a PASTE placeholder is the one thing that stops the whole run, with the line server_id is not set in config.json.

Memory and housekeeping

state.json is the bot's memory: one entry per event id holding the start time, the team, the list of tiers already fired, and the time of the last ping. Entries prune 30 days after the event's start time whenever state is written. Old entries from before the tier system (which record a single pinged_at) are read as fully handled. You never need to touch the file; resetting it to {} makes the bot willing to ping current events again. If the file is ever corrupted, the run logs state.json was unreadable; starting fresh and continues as if it were empty rather than failing.

The commit of state.json after each real run has a side benefit: GitHub disables scheduled workflows in repositories idle for roughly 60 days, and those commits count as activity.

When Raid-Helper moves

Raid-Helper has already changed API domains once (raid-helper.dev to raid-helper.xyz, v4). The script walks a fallback chain of endpoint variants for both the server events listing and the per-event detail, remembers within a run which detail endpoint answered, and accepts a server_events_url override in config.json (with a {sid} placeholder for the server id) that is tried before the built-in chain. If every route returns 404, the log prints a three-cause diagnosis: wrong server id, a key from a different server, or the API moved again. It also logs the creation date encoded in your server_id as a tripwire, and warns if any team's channel_id equals the server_id.

The parsing is deliberately tolerant of shape changes too. The server-events response may be a bare list or a dictionary holding the list under postedEvents, events, or scheduledEvents. Event fields are read under either spelling of channelId/channelid, startTime/starttime, and id/eventId; signups under signUps or signups; and each signup's user under userId, userid, or id. If every event-detail endpoint returns 404, the log says falling back to summary data and the diff uses the signups from the event listing instead of crashing. If both the detail and the listing show zero signups, the run logs a warning that this is either truly zero responses or the API shape changed, and proceeds.

Reading the log

Everything the script decides, it prints. A healthy live run shows the startup line with its mode, the server_id creation-date tripwire, the endpoint that worked, the count of fetched events, and then one block per team: either no upcoming events found in channel, or the event title, hours left, the tier being handled, S/R responded, and the mentioned names (or a DRY RUN line with the would-be message). The log signatures for the common failures are listed in the how-to guides below.

Functions

Features

Three-tier reminder ladder
Per team, a list of tiers with hours_before and message templates. The live setup fires at 120, 72, and 24 hours before raid start; only the most urgent unfired due tier fires on a run.
Chases silence, not attendance
Anyone who responded in any way (signed, Absence, Tentative, Bench, or Late) is excluded; only roster members with no response at all are mentioned.
Fresh recompute per tier
The mention list is rebuilt from live signups every time a tier fires, so answering the event after one ping ends the reminders for that person.
Fire-once memory with collapse
state.json records which tiers fired per event, so a tier fires at most once even with late or duplicate runs; an event first seen inside a smaller window has its moot larger tiers marked done instead of sending a burst.
Channel-locked teams
Each team watches one signup channel id, so only events posted there count. An optional title_contains substring filter narrows further.
Shared named rosters
A top-level rosters table lets several teams reference one roster by name; the live guild runs two signup channels off a single 25-member roster. A Text-formatted reference spreadsheet ships in docs/reference.
Your own message templates
Each tier has one or more templates, picked at random per ping, with placeholders {title}, {when} (a live Discord relative countdown), and {missing} (the mentions). A tier with no templates falls back to the team's own top-level message_templates list, and only if that is absent too to three stock lines.
Hard mention safety
Webhook payloads restrict allowed_mentions to users, so the bot cannot @everyone, @here, or ping a role.
Dry run by default
Manual workflow runs default to dry_run true: the log shows what was found and the exact message, and nothing is posted or saved. ignore_window previews the next tier for an event outside every window. Backup-cron runs are always live.
Runs in the cloud
Executes on GitHub Actions, triggered hourly by an external scheduler; GitHub's own cron at 17 and 47 minutes past each hour stays as a backup because Actions scheduling drifted badly through 2026. Overlapping runs queue in a concurrency group rather than cancelling each other.
Survives Raid-Helper API moves
A fallback chain of endpoint variants, a server_events_url config override, and a printed three-cause diagnosis when every route returns 404. If the per-event detail routes all fail, the diff falls back to the summary signups.
Tolerant response parsing
Accepts the server-events list bare or under postedEvents, events, or scheduledEvents, and reads field names under either spelling (channelId/channelid, startTime/starttime, id/eventId, signUps/signups, userId/userid/id). Warns when an event shows zero signups in both listing and detail.
Startup sanity checks
Logs the creation date decoded from your server_id as a wrong-id tripwire, warns if a team's channel_id equals the server_id, and detects a server_id or channel_id still set to a PASTE placeholder or an empty roster.
Fault isolation between teams
An unknown roster name, a missing webhook URL, or an empty roster skips that team with a logged line; an exception during a team's processing (a failed webhook POST, an API error) is caught by that team's own error handler so the remaining teams still run.
Optional full-signup message
With celebrate_full_signup true, posts a short message when the whole roster has responded. Off by default.
Offline test harness
test_local.py mocks the Raid-Helper API and the Discord webhook at the module boundary and runs 50 checks in 18 groups, including time travel across an event's whole ladder. The suite is sequential and stateful by design; Test 14 reloads nag via importlib to test the un-mocked transport headers. Standard library only, and CI does not run it; you run it locally before shipping a change.

Commands & inputs

CommandWhat it does
/apikeyTyped in Discord in your own server, to the Raid-Helper bot. Returns the per-server API key that goes into the RH_API_KEY secret; the Refresh button on its reply rotates the key.
Actions > Raid-Helper signup nag > Run workflowIn the GitHub web UI. Both inputs (dry_run, ignore_window) default to true, so the plain green button is a safe preview that posts nothing.
POST https://api.github.com/repos/<owner>/<repo>/actions/workflows/nag.yml/dispatchesThe external scheduler's hourly call. Body must be {"ref":"main","inputs":{"dry_run":"false","ignore_window":"false"}} with the quoted strings; success is HTTP 204.
python3 nag.pyOne pass over all teams; what the workflow runs. Needs RH_API_KEY and each team's webhook env var. Set DRY_RUN to 1, true, or yes to preview locally; IGNORE_WINDOW accepts the same three values.
python3 test_local.pyThe 50-check offline suite (18 groups, run in order because later groups build on earlier state). It must end with Total checks passed: 50 before any change ships; CI does not run it for you.

Settings

server_id
Top-level config: your Discord server's id, as text. The script logs the creation date it decodes to; if your server is older than that date, you pasted the wrong id. Left as a PASTE placeholder, the run stops with server_id is not set in config.json.
server_events_url
Top-level config, normally an empty string. Set it to a full Raid-Helper events-endpoint URL with a {sid} placeholder if the API moves domains again; it is tried before the built-in chain.
rosters
Top-level table of named rosters. Each is a list of {"name": ..., "id": ...} entries; id is the Discord user id as text and does the pinging, name is for logs. Entries with a blank id are ignored; a team whose resolved roster is empty is skipped with roster is empty.
teams[].name
Team label used in logs and in state.json.
teams[].channel_id
The team's Raid-Helper signup channel id, as text. Only events posted in this channel are considered. Left blank or as a PASTE placeholder, the team is skipped with channel_id not set in config.
teams[].webhook_env
Name of the environment variable that holds this team's Discord webhook URL. nag.yml must map it from a repository secret of the same name.
teams[].roster
Name of an entry in the rosters table. An inline list of {name, id} entries is also accepted. An unknown name skips that team with roster '<name>' not found in config 'rosters'.
teams[].reminders
The tier ladder: a list of {"hours_before": N, "message_templates": [...]}. Live default 120, 72, 24. Placeholders {title}, {when}, {missing}. A tier without templates uses the team's top-level message_templates list if there is one, and the three stock templates only if there is not.
teams[].title_contains
Optional case-insensitive substring filter on event titles. Empty string accepts any event in the channel.
teams[].celebrate_full_signup
If true, posts a message when the whole roster has responded. Default false.
deadline_hours_before and message_templates (legacy)
Old single-deadline team fields. Still accepted and converted into a one-tier ladder (24 hours if unset). A top-level message_templates list on a team also serves as the fallback for any tier in reminders that has no templates of its own.
RH_API_KEY (repository secret)
The Raid-Helper API key for your server. Required; the run exits with guidance if it is missing. The key is sent raw as the Authorization header value, with no Bearer prefix (unlike the GitHub token the scheduler sends), so store only the key itself.
TEAM1_WEBHOOK, TEAM1_WEBHOOK2, ... (repository secrets)
One Discord webhook URL per destination channel. Each needs a matching NAME: ${{ secrets.NAME }} line in the env block of nag.yml.
dry_run and ignore_window (workflow inputs)
Manual-run inputs, both default true. The scheduler must send both as the string "false" or every triggered run is a silent dry run. nag.yml only reads these inputs on a workflow_dispatch run; a backup-cron run always gets false for both.
DRY_RUN and IGNORE_WINDOW (environment variables)
What nag.py itself reads; the workflow sets them from the inputs above. Any of 1, true, or yes (case-insensitive) counts as on; anything else, including unset, is off.
schedule cron in nag.yml
Backup trigger only, at 17 and 47 minutes past each hour UTC. The primary trigger is the external hourly dispatch.
permissions and concurrency in nag.yml
permissions grants contents: write so the workflow can commit state.json (as rh-nag-bot). concurrency uses the group rh-nag with cancel-in-progress false, so overlapping triggers queue instead of cancelling or racing.

How-to guides

01First raid night with Raid Ping

You have the repository, config, and secrets in place (see the setup guide below) and Raid-Helper has just posted this week's event. Here is what to expect and how to check it is working without pinging anyone by accident.

  1. 1Open your repository on GitHub, go to the Actions tab, select Raid-Helper signup nag, and click Run workflow. Leave dry_run and ignore_window ticked; they default to true.
  2. 2Open the run, expand the nag job, then the Run nagger step. The first line is Run at <UTC time> | dry_run=True ignore_window=True. Look for the line naming the endpoint that worked and the count of fetched events, then a per-team line like: event '<title>' starts in 75.9h, 120h reminder, 20/25 responded, 5 missing.
  3. 3Read the DRY RUN line under it. It lists the roster names it would mention and the exact message. If a name is wrong or missing, fix the roster in config.json and rerun the dry run.
  4. 4If the event is more than 120 hours away, the ticked ignore_window makes the log say outside all windows (X.Xh left), previewing the 120h reminder, followed by the same event and DRY RUN lines. You only see nothing to do yet if ignore_window is off. Either way the preview never writes state.
  5. 5Now leave it alone. The hourly scheduler runs the real pass. When the event crosses 120 hours out, one message appears in the team's nag channel mentioning only the silent raiders.
  6. 6Between tiers, watch people respond. At 72 hours the reminder goes only to whoever is still silent; at 24 hours the urgent line goes out. If your guild uses persistence bonus (PB) rules, this is the message that states the consequence.
  7. 7After each real ping, the workflow commits state.json as rh-nag-bot. Open it in the repository and you will see the event id with stages_done listing the tiers that fired. A trail that stops mid-ladder usually means the event was deleted from Raid-Helper (a rescheduled event or a scheduler outage can leave the same gap); in every case that is fine, the entry prunes itself 30 days after the event's start.

02Set up the repository, config, and secrets

One-time setup, all in the browser. The whole thing is a private GitHub repository with one workflow file, one config file, and two or three encrypted secrets.

  1. 1In Discord, turn on Developer Mode (User Settings > Advanced). This unlocks Copy Server ID on the server icon, Copy Channel ID on a channel, and Copy User ID on a member. On the Discord mobile app, long-press the item to get the same Copy ID option.
  2. 2On github.com click the + at the top right and choose New repository. Name it (for example raid-ping), set visibility to Private because the config will hold your server, channel, and user ids, and create it.
  3. 3Upload the project files, including the .github folder. The workflow must live at .github/workflows/nag.yml or GitHub will not see it.
  4. 4Open config.json in the GitHub editor (pencil icon). Set server_id to your Discord server's id, pasted as text. If you leave the PASTE placeholder in place, the run stops at server_id is not set in config.json.
  5. 5Fill the rosters table with one {"name", "id"} entry per raider. Paste ids as text from Discord, never retype them from screenshots. To verify one, send <@THE_ID> in a quiet channel; Discord renders the owner's name. If you keep the roster in a sheet, start from docs/reference/discord_roster.xlsx, whose id column is already formatted as Text.
  6. 6Set each team's channel_id to its Raid-Helper signup channel and point roster at a roster name. Adjust the reminders ladder and templates if you want; {title}, {when}, and {missing} are the placeholders. A channel_id still reading PASTE skips that team with channel_id not set in config.
  7. 7Commit. If you get a JSONDecodeError naming a line, the missing comma is almost always at the end of the line above it.
  8. 8In your Discord server, type /apikey to the Raid-Helper bot and copy the key. In the channel where reminders should post, open Edit Channel > Integrations > Webhooks > New Webhook and copy the webhook URL.
  9. 9In the repository go to Settings > Secrets and variables > Actions, stay on the Secrets tab (the Variables tab produces empty env vars), and create RH_API_KEY with the API key and TEAM1_WEBHOOK with the webhook URL. Store the Raid-Helper key bare: the script sends it as the Authorization header value with no Bearer prefix.
  10. 10Confirm nag.yml's env block has the line TEAM1_WEBHOOK: ${{ secrets.TEAM1_WEBHOOK }}. GitHub never exposes secrets as env vars on its own; without that line the log says no webhook URL in env var 'TEAM1_WEBHOOK'.
  11. 11Run a dry run from the Actions tab (accept the enable-workflows prompt if GitHub shows one) and read the log before going live.

03Wire the hourly trigger and go live

Do not rely on GitHub's own schedule. Actions cron scheduling drifted by hours and skipped slots through 2026, and the live guild saw near-zero scheduled runs over two days. An external scheduler calling GitHub's workflow-dispatch API is the real clock; the cron in nag.yml stays as a backup.

  1. 1Create a fine-grained personal access token: GitHub avatar > Settings > Developer settings > Personal access tokens > Fine-grained > Generate new. Repository access: only this repository. Permissions: Actions, Read and write. Note the expiry date in your calendar; GitHub also emails you ahead of it.
  2. 2On cron-job.org (or any scheduler that can POST with headers), create an hourly job with the URL https://api.github.com/repos/<owner>/<repo>/actions/workflows/nag.yml/dispatches and method POST.
  3. 3Add three headers in the job's advanced headers section: Authorization: Bearer <token> (the word Bearer, one space, the token), Accept: application/vnd.github+json, and Content-Type: application/json. Leave the separate HTTP-authentication username and password fields empty; putting the token there sends the wrong scheme and returns 401. Note that this Bearer form is for the GitHub token only; the Raid-Helper key in RH_API_KEY is sent bare by the script.
  4. 4Set the body to exactly {"ref":"main","inputs":{"dry_run":"false","ignore_window":"false"}}. The quoted "false" strings are mandatory: GitHub rejects bare booleans, and omitting the inputs makes every triggered run a silent dry run forever, because the manual defaults are true.
  5. 5Use the job's test button. HTTP 204 means success. Be aware the test is a real dispatch: if a reminder window is open and unclaimed, a genuine ping goes out (that is the backlog-clearing behavior working as designed).
  6. 6Turn on the scheduler's failure notifications. They are the system's only alerting; a stream of failure emails almost always means the token expired.
  7. 7In the Actions list, runs from the token show as manually run by the token's owner; runs from GitHub's backup cron are labeled as scheduled. Double-fires are harmless because the rh-nag concurrency group queues them and state dedupe keeps a tier from firing twice.
  8. 8The dispatch call sends no X-GitHub-Api-Version header, so GitHub serves its 2022-11-28 default, which has a sunset date of 2028-03-10. If the scheduler starts reporting 4xx errors around then, add that header with the then-current version to the job.

04Edit the roster or message templates safely

Steady-state upkeep is editing config.json in the browser. The rules below come from real mistakes on the live system.

  1. 1Open config.json in the GitHub editor. Roster changes go in the rosters table; every team pointing at that roster name picks them up.
  2. 2Add a raider as {"name": "Their name", "id": "their Discord user id"}. The id must be text in quotes. Discord ids are 17 to 19 digits, more than spreadsheets keep precisely, so if you track the roster in a sheet format the id column as Text before pasting; the shipped docs/reference/discord_roster.xlsx already has that done.
  3. 3Mind the commas: every entry but the last in a list needs one. If the commit produces a JSONDecodeError at line N, look at the end of line N-1.
  4. 4To change wording, edit a tier's message_templates. Keep {title}, {when}, and {missing} where you want the event name, the countdown, and the mentions. Add more than one string to a tier and one is picked at random per ping. A tier you leave without templates borrows the team's top-level message_templates list if one exists, otherwise the three stock lines. Never put a webhook URL or key in a template.
  5. 5Commit, then run a dry run from the Actions tab with both inputs ticked. A syntax error fails before any network call, and the DRY RUN line shows the new names and wording.
  6. 6To pause a team without deleting it, remove its webhook secret's line from nag.yml's env block; the log will say no webhook URL in env var and skip that team.

05Add a second team or signup channel

The live guild runs a main night and a second night from one roster. A new destination always takes three linked steps: a webhook, a secret, and one env line in the workflow.

  1. 1In config.json add another entry to teams with its own channel_id, a roster (the same name as the first team if they share raiders), its own reminders ladder, and "webhook_env": "TEAM2_WEBHOOK".
  2. 2In Discord, open the channel where that team's reminders should post: Edit Channel > Integrations > Webhooks > New Webhook, and copy the URL.
  3. 3In the repository, Settings > Secrets and variables > Actions > Secrets tab, create TEAM2_WEBHOOK with that URL.
  4. 4Edit .github/workflows/nag.yml and add TEAM2_WEBHOOK: ${{ secrets.TEAM2_WEBHOOK }} under env in the Run nagger step, next to the existing TEAM1_WEBHOOK lines.
  5. 5Run a dry run and confirm the new team's lines appear in the log and that it no longer says no webhook URL in env var 'TEAM2_WEBHOOK'.
  6. 6If both teams share one signup channel but different event names, use title_contains on each team to route by title instead of by channel.

06Read the run log and fix the common failures

Everything Raid Ping knows, it prints. Open Actions, pick the run, expand the Run nagger step. These are the signatures seen on the live system and what each one means.

  1. 1Endpoint GET ... not found on every variant, followed by a boxed three-cause diagnosis: the server_id is wrong (compare the creation date it logs against your server's real age), the API key came from a different server (rerun /apikey in the right one), or Raid-Helper moved its API again (paste the new events URL into server_events_url with {sid} where the server id goes).
  2. 2The API rejected the key (HTTP 401 or 403): rerun /apikey and update the RH_API_KEY secret with only the key, no quotes, spaces, or Bearer prefix.
  3. 3unknown url type: '***': the webhook secret is not a URL. GitHub masks secrets in logs, so the asterisks are expected; recreate the webhook in Discord and paste the full URL into the secret.
  4. 4Webhook POST failed: HTTP 403: either the webhook was deleted in Discord, or the custom User-Agent was removed from the script. Discord sits behind Cloudflare, which rejects Python's default user agent; the tests assert the header is present.
  5. 5no webhook URL in env var 'NAME', skipping: the env line in nag.yml is missing for that secret. Add NAME: ${{ secrets.NAME }} to the env block. The sibling lines channel_id not set in config, roster is empty, and roster '<name>' not found in config 'rosters' each skip one team the same way.
  6. 6JSONDecodeError line N col C: a missing comma at the end of line N-1 in config.json. Fix it and rerun the dry run.
  7. 7handled under pre-stages state format: an old state entry from before the tier system. Healthy; it prunes 30 days after the event.
  8. 8warning: no signups found on event ... either truly zero responses or the API shape changed. Proceeding: the event shows zero signups in both the listing and the detail. If you know people have signed, Raid-Helper's response shape may have changed; if nobody has, this is expected and the whole roster will be mentioned.
  9. 9falling back to summary data: every per-event detail endpoint returned 404, so the diff used the signups from the event listing. Reminders still go out; check whether Raid-Helper moved the detail route.
  10. 10state.json was unreadable; starting fresh: the state file failed to parse. The run treats it as empty, which means current events may be pinged again; fix or reset the file to {}.
  11. 11[team] error: ...: an exception during that team's processing (for example a failed webhook POST). That team is skipped for this run and the others continue; the repr in the line says what failed.
  12. 12No runs appearing at all for hours: check the scheduler's failure emails. A 401 there means the personal access token expired or the Authorization header lost its Bearer prefix; generate a new token and paste it into the job.
  13. 13If a webhook URL or API key ever leaks, rotate it immediately: delete and recreate the webhook in Discord, or press Refresh on the /apikey reply, then update the secret.

Good to know

  • Do not rely on GitHub's built-in cron. Actions scheduling drifted by hours and skipped slots through 2026; the external hourly dispatch is the real scheduler and the 17,47 * * * * cron in nag.yml is only a backup.
  • Workflow-dispatch inputs must be the quoted strings "false". GitHub rejects bare booleans, and omitting the inputs makes every triggered run a silent dry run forever, because the manual defaults are true. Backup-cron runs ignore the inputs and are always live.
  • Every webhook needs a three-link chain: the team's webhook_env name in config, an explicit NAME: ${{ secrets.NAME }} line in nag.yml's env block, and the secret itself. Missing the yml line logs no webhook URL in env var 'NAME'.
  • Use the Secrets tab, not the Variables tab, under Settings > Secrets and variables > Actions. Variables produce empty env vars.
  • Two different header schemes are in play. The scheduler sends the GitHub token as Authorization: Bearer <token>; the script sends the Raid-Helper key raw as the Authorization value with no Bearer. Store RH_API_KEY as the bare key.
  • Discord sits behind Cloudflare and rejects Python's default User-Agent with HTTP 403. The script sends a custom User-Agent on every request and the tests assert it. Never remove it.
  • The 120-hour tier can only fire if Raid-Helper posts the event at least five days out. If events post later, collapse skips the moot tiers automatically; nothing is wrong.
  • Discord ids are 17 to 19 digit numbers that exceed Excel's precision. Keep them as text everywhere and format spreadsheet columns as Text before pasting (docs/reference/discord_roster.xlsx ships pre-formatted); corruption is silent.
  • A JSONDecodeError naming line N usually means a missing comma at the end of line N-1. The dry run fails before any network call, so it is a safe validator.
  • The Raid-Helper API key is per server. A key from another server yields Endpoint not found on every route. The API already moved domains once, which is why the fallback chain and server_events_url override exist.
  • Fine-grained GitHub tokens expire. GitHub emails a warning ahead of the date, but once the token has expired the only symptom is 401s at the scheduler, so keep the scheduler's failure notifications on and calendar the renewal.
  • The dispatch call sends no X-GitHub-Api-Version header, so GitHub serves its 2022-11-28 default, which sunsets on 2028-03-10. If 4xx errors start around then, add the header with the current version to the scheduler job.
  • GitHub disables scheduled workflows in repositories idle for about 60 days. state.json commits from real runs count as activity.
  • A stage trail that stops mid-ladder in state.json usually means the event was deleted from Raid-Helper. The engine simply never sees it again and prunes the entry 30 days after the event start.
  • Resetting state.json to {} makes the bot willing to ping all current events again. An unreadable state.json has the same effect: the run logs it and starts fresh instead of failing.
  • The scheduler's test button performs a real dispatch, so it can deliver a genuine ping if a reminder window is open and unclaimed.
  • Rescheduling a raid moves every tier with it, because every window is computed from the event's own start time.
  • test_local.py is not run by CI. Its 18 groups are sequential and stateful, so run the whole file, and a new test group must install its own mocks rather than trusting the previous group's.
  • If a webhook URL or API key leaks, rotate it: delete and recreate the webhook in Discord, or press Refresh on the /apikey reply, then update the secret.

Visuals

Every hour, a cloud scheduler wakes a GitHub Actions job that compares Raid-Helper signups to your roster and pings only the silent, then commits its memory back to the repo.
Each rung of the 120/72/24-hour ladder recomputes who is still silent, so the mention list shrinks as people answer and the final urgent ping reaches only the last holdout.
When an event is first seen inside a smaller window, the tiers already behind it are marked done in state.json so a late posting never triggers a burst of stacked pings.

Requirements & credits

Requirements

  • A Discord server where the Raid-Helper bot posts signup events, with one signup channel per team
  • A Raid-Helper API key for that server, obtained by typing /apikey to the bot in the server itself
  • A private GitHub repository with GitHub Actions enabled (the config holds Discord ids, so keep it private)
  • One Discord incoming webhook per channel where reminders should post, stored as a repository secret
  • An external hourly scheduler that can POST with custom headers (the live setup uses cron-job.org) holding a fine-grained GitHub token scoped to this repository with Actions read and write
  • Python 3.10 or newer on the runner (GitHub's ubuntu-latest has it); standard library only, no pip packages
  • Discord user ids for every raider on the roster, copied as text with Developer Mode on

Credits

  • Raid-HelperThe Discord event and signup bot whose public REST API Raid Ping reads. Raid-Helper is a separate project by its own authors; Raid Ping is an unofficial client and is not affiliated with or endorsed by Raid-Helper.
  • Discord webhooksReminders post through standard Discord incoming webhooks with allowed_mentions restricted to users. RaidKit is not affiliated with Discord.
  • GitHub ActionsHosts and runs the script and commits state.json back. RaidKit is not affiliated with GitHub.
  • cron-job.orgThe free external scheduler the live deployment uses as its primary hourly trigger. RaidKit is not affiliated with cron-job.org.
  • GargulThe live guild's 24-hour reminder mentions persistence bonus (PB), the guild's loot-priority points policy, which is managed by a separate addon: Gargul Persistence Bonus, an unofficial companion to Gargul that keeps the loot ledger and is a sibling project to Raid Ping. Raid Ping shares the guild policy only, no code. Gargul is a separate project by its authors, and RaidKit is not affiliated with or endorsed by Gargul.
  • Blizzard EntertainmentWorld of Warcraft is a trademark of Blizzard Entertainment. RaidKit is not affiliated with Blizzard.

Download & install

Set it up

  1. 1The repository is private today because config.json holds Discord server, channel, and user ids. When it is published you will fork it; until then this checklist describes the shape of a deployment.
  2. 2Create a private GitHub repository and add the project files, including .github/workflows/nag.yml (which carries permissions contents: write and the rh-nag concurrency group).
  3. 3Fill config.json: server_id, the rosters table (Discord user ids as text; docs/reference/discord_roster.xlsx is a Text-formatted starting sheet), and one teams entry per signup channel with channel_id, roster, webhook_env, and a reminders ladder. Replace every PASTE placeholder or the run skips that item.
  4. 4Add repository secrets under Settings > Secrets and variables > Actions > Secrets: RH_API_KEY (the bare key from /apikey in your Discord server, no Bearer prefix) and TEAM1_WEBHOOK (a Discord incoming webhook URL for the reminder channel). Make sure nag.yml maps each webhook secret in its env block.
  5. 5Run the first dry run: Actions > Raid-Helper signup nag > Run workflow with dry_run and ignore_window left at true. Read the Run nagger log; nothing is posted and no state is written.
  6. 6Wire an external hourly scheduler to POST the workflow-dispatch URL with a fine-grained token (Actions read and write on this repository) and the body {"ref":"main","inputs":{"dry_run":"false","ignore_window":"false"}}. Confirm HTTP 204 and turn on failure notifications.
  7. 7Optional, for anyone changing the script: run python3 test_local.py locally and expect Total checks passed: 50. CI does not run the suite.

Changelog

  1. 2026-09-01
    • Repository consolidated into the development workspace as a live git clone; deployment is now commit and push instead of web-editor pastes.
    • Canonical 50-check test_local.py committed, replacing the original 17-check version.
    • CLAUDE.md, CHANGELOG.md, MIGRATION.md, and docs/ moved into the repository so it is self-contained for handoff.
    • README rewritten for the three-tier ladder, collapse, shared rosters, the external trigger as primary with the string-inputs gotcha, the webhook three-link chain, and dry-run defaults.
    • Reconciliation with the live repository: nag.py confirmed byte-identical to the tested 2026-08-10 baseline; the 50-check suite passes against it.
    • Workflow confirmed: backup cron 17,47 * * * *, checkout@v5, both webhook env lines present.
    • Config confirmed: 120/72/24 ladder on both team entries with one template per tier; server_events_url present and empty.
    • state.json shows three weeks of live history across 10 events, four of them completing the full natural 120, 72, 24 ladder.
  2. 2026-08-10
    • Multi-stage reminder engine: a per-team reminders list replaces the single deadline; the most urgent unfired due stage fires; moot larger tiers are marked done (collapse) when an event is first seen inside a smaller window.
    • State moves to per-event stages_done lists, with a shim treating pre-stage entries as fully handled. Legacy single-deadline configs still work.
    • Live config restructured to the 120/72/24 ladder, each tier with its own templates. A missing-comma JSONDecodeError established the dry-run-as-validator practice.
    • Test suite grown to 50 checks, including the three-tier ladder end to end with time travel.
  3. 2026-08-06
    • GitHub Actions cron had produced near-zero scheduled runs over two days, matching community reports of 2026 degradation.
    • cron-job.org wired as the primary trigger: hourly POST to the workflow-dispatch API with a fine-grained token and explicit string inputs, failure notifications on.
    • The first test dispatch returned 204 and delivered a real backlog ping for an open, unclaimed window, as designed.
    • A 401 during setup traced to header placement and the Bearer prefix; documented in the operations notes.

Full changelog for Raid Ping

More from RaidKit