I got tired of refreshing a pile of careers pages, so I wanted a job notifier that does the filtering for me. Most companies serve their open roles from a public JSON API (the same one their careers page calls). This subscribes to those feeds, keeps only the roles that match my filters, remembers what it has already seen, and pings me on Discord when a new one shows up.
A job notifier I built to filter openings the way I want and get a Discord ping
the moment something matches. It polls company career APIs on a schedule and only
sends postings that are new and match my keywords and locations. The monitor has no
third-party dependencies; Python 3.8+ standard library only. A separate /linkedin
Discord command searches LinkedIn on demand (see below).
Live jobs page: all currently-open matches, refreshed on each scheduled run — capy-zhiao.github.io/Job_Monitor
Search across every source, filter by role category (Security / AI-ML / SDE / Research), country (Canada / US), work type (full-time / intern), city, and recency, and star / mark-applied any posting (kept in your browser). A one-line summary sits above the list, which is grouped by the day each role was posted (or first seen, when the source gives no date). Same data as the Discord alerts, browsable.
| Type | Endpoint | Example companies |
|---|---|---|
greenhouse |
boards-api.greenhouse.io/v1/boards/<board>/jobs |
Stripe, Datadog, Coinbase, Okta |
lever |
api.lever.co/v0/postings/<company> |
Palantir, Waabi, Magnet Forensics |
ashby |
api.ashbyhq.com/posting-api/job-board/<board> |
OpenAI, Cohere, 1Password, Vanta |
amazon |
amazon.jobs/en/search.json |
Amazon |
workday |
<host>/wday/cxs/<tenant>/<site>/jobs |
TD, CrowdStrike, Trend Micro, Arctic Wolf |
phenom |
<host>/widgets |
RBC, eBay |
eightfold |
<host>/api/apply/v2/jobs |
Netflix |
recruitee |
<board>.recruitee.com/api/offers/ |
Huawei Canada |
google |
Careers batchexecute RPC (r06xKb), JSON behind the results page |
|
microsoft |
apply.careers.microsoft.com/api/pcsx/search (Eightfold) |
Microsoft |
apple |
jobs.apple.com/api/v1/search |
Apple |
shopify |
Shopify Careers page (HTML scrape) | Shopify |
github_listings |
listings.json from community new-grad list repos |
SimplifyJobs/New-Grad-Positions, vanshb03/New-Grad-2027 |
github_tables |
markdown tables (no JSON) in community list repos | speedyapply/2027-SWE-College-Jobs (international new-grad and internship lists) |
workable |
apply.workable.com/api/v1/widget/accounts/<account> |
Genetec, Valsoft |
bamboohr |
<company>.bamboohr.com/careers/list |
BinSentry, EUNA |
rippling |
ats.rippling.com/api/v2/board/<board>/jobs |
D-Wave, nesto, Webull |
icims_portal |
classic careers-<company>.icims.com search pages (HTML) |
Kinaxis |
jobvite |
jobs.jobvite.com/<company>/jobs (HTML) |
Absolute Security |
successfactors |
<host>/services/rss/job/ (jobs2web RSS) |
SAP |
icims |
<host>/api/jobs (iCIMS Career Sites / Jibe) |
AMD |
oracle |
Oracle Recruiting Cloud recruitingCEJobRequisitions REST |
Fortinet, Uber |
pcsx |
Eightfold /api/pcsx/search |
Ericsson |
dayforce |
<region>.dayforcehcm.com/Api/<client>/V1/JobFeeds |
eSentire |
smartrecruiters |
api.smartrecruiters.com/v1/companies/<X>/postings |
ServiceNow |
jazzhr |
<board>.applytojob.com/apply (HTML) |
Xanadu |
ibm |
www-api.ibm.com/search/api/v2 (Elasticsearch proxy) |
IBM |
job_monitor/
├── run.py # entry point: python3 run.py
├── config.json # sources, keywords, locations
├── pyproject.toml
├── jobmonitor/
│ ├── cli.py # argument parsing + orchestration
│ ├── fetchers.py # one adapter per platform + FETCHERS registry
│ ├── filters.py # title/location matching
│ ├── notify.py # Discord / Slack webhooks
│ ├── state.py # seen.json persistence
│ ├── http.py # urllib helpers
│ └── models.py # Job dataclass
├── tests/ # unittest suite (offline, HTTP mocked)
└── .github/workflows/monitor.yml
# Preview: fetch and print matches, send nothing, write nothing
python3 run.py --dry-run
# Live run. First run seeds state silently; later runs notify on new jobs only.
export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/xxxx/yyyy"
python3 run.py
# Force notifications on the first run too
python3 run.py --notify-firstCreate a Discord webhook via: channel settings, Integrations, Webhooks, New
Webhook, Copy URL. For Slack, create an Incoming Webhook and set
SLACK_WEBHOOK_URL. If both are set, both receive notifications.
Three global fields plus a list of sources:
keywords: case-insensitive substring match on the job title. A title matches if it contains any keyword. A year keyword ("2027") only counts when the title also names technical work, so "Finance Intern - Winter 2027" stays out.exclude_keywords: if the title contains any of these, the job is dropped even when a keyword matched. Independently of this list, a level above entry anywhere in the title ("Software Engineer II - Backend", "Cloud Engineer 2") drops the job unless the title also says new grad, graduate, university or early career, or a keyword names that level itself.locations: a job is kept only if its location contains one of these terms. An empty list disables location filtering.sources: one entry per feed. Any source can overridekeywords,exclude_keywords, orlocationsjust for itself.
Add a company. Find which platform it uses by opening its careers page and
watching the network requests (F12). A request to
boards.greenhouse.io/embed/job_board?for=<x> means Greenhouse (board <x>),
jobs.lever.co/<x> means Lever, <x>.myworkdayjobs.com means Workday, and
jobs.ashbyhq.com/<x> means Ashby. Then add one entry to sources. For most
platforms it is a single line:
{ "type": "greenhouse", "name": "Stripe", "board": "stripe" }Workday needs a bit more (host, tenant, site), and you should give it a
country plus a client-side location filter:
{
"type": "workday",
"name": "TD",
"host": "td.wd3.myworkdayjobs.com",
"tenant": "td",
"site": "TD_Bank_Careers",
"search": "software engineer",
"country": "CAN",
"pages": 3
}Change what counts as a match. Edit keywords to describe the roles you
want (for example "new grad", "software engineer i,", "security analyst")
and exclude_keywords to cut the noise (for example "senior", "staff",
"manager", "intern"). Matching is on the title only.
Change where. Edit locations to the cities or countries you care about
(for example "toronto", "vancouver", "canada"). Leave it empty to allow
anywhere.
Filter a single company differently. Put keywords or locations inside a
source to override the global lists there. This is handy when one company uses
odd titles, for example a security-only search at Amazon:
{
"type": "amazon",
"name": "Amazon Security",
"query": "security engineer",
"countries": ["CAN"],
"keywords": ["security engineer i,", "new grad", "early career"]
}Subscribe to the GitHub new-grad lists. The github_listings type reads the
structured listings.json that the community-maintained new-grad repos generate
their README tables from, so every posting the crowd tracks lands in the same
Discord channel as the company feeds. One source entry covers any number of
list repos; postings appearing in several lists are de-duplicated by apply URL,
listings marked as requiring U.S. citizenship are dropped at the source, and
max_age_days keeps the initial backfill sane. Roles marked "Offers Sponsorship"
get a visa tag on the page, but the lists rarely fill this field in: of about
19,000 SimplifyJobs entries in October 2026, only 12 said they sponsor and 15 that
they do not.
{
"type": "github_listings",
"name": "GitHub New-Grad Lists",
"urls": [
"https://raw.githubusercontent.com/SimplifyJobs/New-Grad-Positions/dev/.github/scripts/listings.json",
"https://raw.githubusercontent.com/vanshb03/New-Grad-2027/main/.github/scripts/listings.json"
],
"max_age_days": 30
}Some lists publish only a markdown table. The github_tables type parses the
| Company | Position | Location | Posting | Age | rows of
speedyapply/2027-SWE-College-Jobs
(NEW_GRAD_INTL.md and INTERN_INTL.md, filtered to Canada, plus NEW_GRAD_USA.md,
filtered to the same US tech cities as the other list source). Rows that don't parse are
skipped, as are roles that require a security clearance or U.S. citizenship, and
max_age_days is applied to the age column. The tables have no sponsorship field, so
unlike github_listings this source cannot drop U.S. roles that offer no sponsorship.
Note the branch in each raw URL (Simplify uses dev, not main). If a list
starts returning 404, check whether the repo moved the file or renamed its
default branch.
Big-tech direct sources. Microsoft, Apple, and Google don't use a standard ATS, so each has a dedicated fetcher (verified 2026-08). Quirks worth knowing when they break:
microsoft— Eightfold PCSX API, GET withquery/locationparams. Page size is pinned to 10 (numbelow 10 is ignored). The pre-2026gcsservices.careers.microsoft.comendpoint is dead (stale TLS cert).apple— POSTapi/v1/search; the body must include thesortandformatkeys or the API silently returns zero results. Multi-location postings repeat per location; the fetcher dedupes onpositionId.google— the careers page is a JS app; jobs come from abatchexecuteRPC with a double-encodedf.reqform field and a)]}'response prefix. The oldtarget_level=EARLYserver-side filter is defunct; entry-level filtering is client-side on titles like every other source.- Workday tenants with custom facet GUIDs (e.g. NVIDIA's
locationHierarchy1) can pass them via a rawfacetsobject on the source entry. - Workday lists a multi-location posting as "2 Locations". For postings whose
title matched,
resolve_workdayasks the job's detail endpoint for the real places (and exact posting date) before the location filter runs.
Overlapping queries (e.g. "Microsoft" + "Microsoft (Security)") may return the
same posting; collect_matches dedupes globally on uid. The same posting also
arrives from its company's feed and from community lists under different uids,
so the run then dedupes on the posting URL (tracking parameters dropped), keeping
the company's copy, and records seen URLs as well as uids. A repost gets a new
URL and alerts again.
Posting dates come from each API where it has one (Greenhouse first_published,
Lever createdAt, Ashby publishedAt, Workday's "Posted 3 Days Ago", the lists'
date_posted, and so on; about nine in ten postings have one). The rest fall back
to the day the monitor first saw them.
A good loop is: run python3 run.py --dry-run, look at how many jobs each source
returns and how many match, then tighten or loosen the keywords until the matches
are the ones you would actually apply to. Only then schedule it.
How to test if it is still running: tail -3 /tmp/job_monitor.log
The suite uses the standard-library unittest and mocks the HTTP layer, so it
runs offline with no dependencies. Run it from the project root:
cd job_monitor # the directory containing run.py and tests/
python3 -m unittest discover -s tests -vIt covers the title/location filters, the seen.json state round-trip, each
platform's response parsing and posting dates, URL dedupe, and the source-health
rules.
Local cron (requires the machine to be awake):
*/15 * * * * DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/xxxx/yyyy" /usr/bin/python3 /path/to/job_monitor/run.py >> /tmp/job_monitor.log 2>&1GitHub Actions (recommended, runs without your machine): push this repo to
your own GitHub repository, add DISCORD_WEBHOOK_URL as a repository secret, and
the included .github/workflows/monitor.yml runs on a 15-minute cron and commits
seen.json back to persist state. GitHub delays scheduled runs on busy days; this
repo's runs usually land a few hours apart. The commit step rebases onto main
before pushing, so a push made during a run does not lose that run's state.
Source health. Each run records every source's result in health.json. A
source that fails three runs in a row, or returns no postings at all for three
days, is reported once to the Discord channel, and again when it recovers. The
jobs page footer shows how many sources responded on the last run.
The Actions run also emits every currently-matched posting to docs/jobs.json
(via run.py --emit-json), which docs/index.html renders as a static,
searchable page served by GitHub Pages: search box, per-company filter, and
postings grouped by the day they were posted (first seen, when the source has
no date). Discord is where new matches arrive; the page is the browsable full
picture. A first_seen.json sidecar keeps stable discovery dates across runs.
Each row shows the company's icon. Most postings link to an applicant tracking
system rather than the company's site, so company_domains.json maps company
names to their domains by hand; a company missing from it shows its initial
instead of a guessed (possibly wrong) logo. The run fetches each mapped domain's
favicon once into docs/logos/, so the page serves the icons itself.
LinkedIn isn't one of the scheduled sources: it blocks datacenter IPs like
GitHub's runners, and its terms don't allow automated scraping. Instead, a small
Discord bot answers /linkedin in the alerts channel by searching LinkedIn on
the spot from a home connection and posting the entry-level results the
scheduled monitor isn't already reporting. It keeps no state and never writes to
the repo.
/linkedin # 11 preset searches (security, software, research), Canada, last 24 hours, 3 pages each
/linkedin keywords:"backend engineer" # any search
The reply header counts what was found: new results, ones the scheduled monitor
already reports, and ones dropped as senior or posted by a staffing agency.
Setup is in discord_bot/.
seen.json is a flat list of job identifiers and posting URLs (url: entries).
Both are only ever added, so a posting that disappears from a feed and later
returns will not notify twice.
Delete seen.json to reset (the next run becomes a fresh first run).


