AI disclosure: This article was written with the help of AI. I used an AI assistant to draft and edit the text and to tidy the code. I know this API from building tools on it, and I ran every request and the full script below against the live Grants.gov API on October 26, 2026 before publishing. All the numbers in the post come from those runs.
If you write grants, or work at a nonprofit, school district, tribe or city that depends on federal funding, you probably know this routine: open Grants.gov, run the same few searches, scroll past things you've already seen, and try to remember which deadlines moved since last week.
Grants.gov has a public REST API that returns JSON, with no API key and no login. This post shows how to use it to build a small weekly job that:
- runs your saved searches,
- drops loose keyword matches,
- outputs only opportunities you haven't seen before, plus ones whose deadline or status changed,
- writes them to a CSV and, optionally, posts them to Slack.
It uses only the Python standard library, so there's nothing to install.
1. The search endpoint
The endpoint is POST https://api.grants.gov/v1/api/search2 with a JSON body. Try it with curl:
curl -s -X POST https://api.grants.gov/v1/api/search2 \
-H 'Content-Type: application/json' \
-d '{"keyword": "broadband", "oppStatuses": "forecasted|posted", "rows": 3}'
The useful part of the response is under data:
{
"hitCount": 21,
"startRecord": 0,
"oppHits": [
{
"id": "362843",
"number": "2026-NTIA-TBCP",
"title": "Tribal Broadband Connectivity Program",
"agencyCode": "DOC-DOCNISTERA",
"agency": "DOC NIST ERA",
"openDate": "06/17/2026",
"closeDate": "11/17/2026",
"oppStatus": "posted",
"docType": "synopsis",
"cfdaList": ["11.029"]
}
]
}
(hitCount is from my run on October 26, 2026. Yours will differ.)
The request fields I use most:
| Field | What it does | Example |
|---|---|---|
keyword |
Full-text search | "mental health" |
oppStatuses |
Pipe-separated statuses | `"forecasted\ |
{% raw %}eligibilities
|
Applicant type codes, pipe-separated | `"12\ |
{% raw %}agencies
|
Agency codes, pipe-separated | "HHS-NIH11" |
fundingCategories |
Category codes |
"ENV", "HL", "ED"
|
rows, startRecordNum
|
Page size and offset for paging |
100, 0
|
A few other eligibility codes that come up often: 00 state governments, 01 county governments, 02 city or township governments, 05 independent school districts, 07 federally recognized tribal governments, 23 small businesses. The response also includes facet lists (eligibilities, agencies, fundingCategories) with labels and counts, so you can discover codes from the API itself.
Two things to know about the response: dates are MM/DD/YYYY strings, and closeDate is empty for forecasts and for rolling notices that have no deadline.
2. Three gotchas I hit
Keyword search is very broad. It's full-text with stemming, so a keyword matches any notice that mentions it anywhere. On October 26, 2026, "mental health" limited to nonprofit eligibility (12|13) returned 482 forecasted or posted opportunities, and "housing" returned 137. When I kept only rows with the keyword in the title, the two searches together came down to 15 opportunities. Filtering on the title (or on the description, see step 5) is what turns this into a useful feed.
Top-level agency codes return nothing. Opportunities are filed under sub-agency codes, so {"agencies": "HHS"} and {"agencies": "USDA"} both returned hitCount: 0 for me, while "HHS-NIH11" (NIH) returned 662. Expand a top-level code using the agencies facet in any search response:
def expand_agency(top_code):
facets = post({"oppStatuses": "forecasted|posted", "rows": 0})["agencies"]
for a in facets:
if a["value"] == top_code:
return [s["value"] for s in a["subAgencyOptions"]] or [top_code]
return [top_code]
# expand_agency("USDA") -> ['USDA-AMS', 'USDA-FAS', 'USDA-FS', 'USDA-NIFA', ...]
# then search with {"agencies": "|".join(expand_agency("USDA"))}
(The facet only lists sub-agencies that have current opportunities, which is what you want here.)
You have to page. rows caps a page, so loop on startRecordNum until you've read hitCount rows. For scale: all forecasted and posted opportunities together were 1,457 on the day I tested, so even a full scan is about 15 requests of 100 rows.
3. The script
Save this as grant_watch.py. Edit the four settings at the top.
#!/usr/bin/env python3
"""Weekly Grants.gov watcher: prints/saves only opportunities you haven't seen yet."""
import csv, datetime, json, os, re, sys, urllib.request
API = "https://api.grants.gov/v1/api/search2"
STATE_FILE = "seen_grants.json"
KEYWORDS = ["mental health", "housing"] # one search per keyword
ELIGIBILITIES = "12|13" # 501(c)(3) and other nonprofits
STATUSES = "forecasted|posted"
TITLE_MUST_MATCH = True # drop hits that only mention the keyword deep in the text
PAGE_SIZE = 100
def search(keyword):
"""Yield every hit for one keyword, page by page."""
start = 0
while True:
body = {"keyword": keyword, "oppStatuses": STATUSES,
"eligibilities": ELIGIBILITIES, "rows": PAGE_SIZE,
"startRecordNum": start}
req = urllib.request.Request(API, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.load(resp)["data"]
hits = data["oppHits"]
yield from hits
start += len(hits)
if not hits or start >= data["hitCount"]:
break
def title_matches(title, keyword):
return re.search(r"\b" + re.escape(keyword) + r"\b", title, re.I) is not None
def days_left(close_date):
if not close_date:
return None
d = datetime.datetime.strptime(close_date, "%m/%d/%Y").date()
return (d - datetime.date.today()).days
def main():
seen = {}
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
seen = json.load(f)
current = {}
for kw in KEYWORDS:
for hit in search(kw):
if TITLE_MUST_MATCH and not title_matches(hit["title"], kw):
continue
row = current.setdefault(hit["id"], {
"id": hit["id"], "number": hit["number"], "title": hit["title"],
"agency": hit["agency"], "status": hit["oppStatus"],
"openDate": hit["openDate"], "closeDate": hit["closeDate"],
"daysLeft": days_left(hit["closeDate"]), "keywords": [],
"url": f"https://www.grants.gov/search-results-detail/{hit['id']}",
})
row["keywords"].append(kw)
new_rows = []
for opp_id, row in current.items():
before = seen.get(opp_id)
if before is None:
row["change"] = "new"
elif before["closeDate"] != row["closeDate"]:
row["change"] = f"deadline moved (was {before['closeDate'] or 'none'})"
elif before["status"] != row["status"]:
row["change"] = f"status {before['status']} -> {row['status']}"
else:
continue
new_rows.append(row)
new_rows.sort(key=lambda r: (r["daysLeft"] is None, r["daysLeft"] or 0))
for r in new_rows:
left = "no deadline" if r["daysLeft"] is None else f"{r['daysLeft']} days left"
print(f"[{r['change']}] {r['title']} | {r['agency']} | {left}\n {r['url']}")
if new_rows:
out = f"new_grants_{datetime.date.today().isoformat()}.csv"
with open(out, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(new_rows[0].keys()))
w.writeheader()
for r in new_rows:
w.writerow({**r, "keywords": ", ".join(r["keywords"])})
print(f"\n{len(new_rows)} new/changed of {len(current)} matching -> {out}")
else:
print(f"Nothing new ({len(current)} matching opportunities already seen).")
# Optional: post a summary to Slack via an incoming webhook.
webhook = os.environ.get("SLACK_WEBHOOK_URL")
if webhook and new_rows:
lines = [f"• <{r['url']}|{r['title']}> ({r['agency']}, closes {r['closeDate'] or 'n/a'})"
for r in new_rows[:30]]
msg = {"text": f"{len(new_rows)} new/changed federal grant opportunities:\n" + "\n".join(lines)}
req = urllib.request.Request(webhook, data=json.dumps(msg).encode(),
headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=30)
seen.update({k: {"closeDate": v["closeDate"], "status": v["status"]} for k, v in current.items()})
with open(STATE_FILE, "w") as f:
json.dump(seen, f)
if __name__ == "__main__":
sys.exit(main())
The idea that makes it a monitor rather than a search is seen_grants.json: a map of opportunity ID to the deadline and status we last saw. Anything not in it is new. Anything in it with a different closeDate or oppStatus is a change worth re-sending, which catches deadline extensions and forecasts that open.
4. Run it
First run (it treats everything as new and saves the baseline):
$ python3 grant_watch.py
[new] Accelerating Solutions to Improve Access and Quality of Empirically-Supported Practices for Youth Mental Health (R01 Clinical Trial Optional) | National Institutes of Health | 73 days left
https://www.grants.gov/search-results-detail/357524
...
15 new/changed of 15 matching -> new_grants_2026-10-26.csv
Second run, right after:
$ python3 grant_watch.py
Nothing new (15 matching opportunities already seen).
That second line is the point. From now on, each run shows only what changed.
5. Optional: pull the full record
The search results don't include award amounts or eligibility text. For those, call fetchOpportunity with the ID:
curl -s -X POST https://api.grants.gov/v1/api/fetchOpportunity \
-H 'Content-Type: application/json' -d '{"opportunityId": 362843}'
The interesting fields are under data.synopsis: synopsisDesc (the description, as HTML), applicantEligibilityDesc, awardCeiling, awardFloor, numberOfAwards, costSharing and responseDate. Note that awardCeiling can be the string "none", so don't assume it's a number. Attachments (the NOFO documents) are under data.synopsisAttachmentFolders.
That's one extra request per opportunity, so only fetch details for the new rows, not the whole result set. It's also where you can apply a stricter filter, for example keep a row only if the keyword is in the title or the description.
One caution: the same record includes the agency contact's name, email and phone. If you're storing these rows in a shared sheet or posting them to Slack, leave those fields out and link to the notice instead (the url column).
6. Schedule it weekly
Cron on any machine you leave on:
0 8 * * 1 cd /path/to/grant-watch && /usr/bin/python3 grant_watch.py >> watch.log 2>&1
Or GitHub Actions, with no server. Put grant_watch.py in a repo and add .github/workflows/grant-watch.yml. The job commits seen_grants.json back to the repo so the next run remembers what it already sent:
name: grant-watch
on:
schedule:
- cron: "0 12 * * 1" # Mondays 12:00 UTC
workflow_dispatch:
permissions:
contents: write
jobs:
watch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: python3 grant_watch.py
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
- name: Save state
run: |
git config user.name "github-actions[bot]"
git config user.email "41898732+github-actions[bot]@users.noreply.github.com"
git add seen_grants.json
git commit -m "Update seen grants" || echo "No changes"
git push
Add your Slack incoming-webhook URL as a repository secret named SLACK_WEBHOOK_URL (or skip it and read the CSV/log). Scheduled runs can start a few minutes late, which doesn't matter for a weekly job. Agencies post on business days, so weekly or daily both work.
7. Ideas to extend it
-
One state file per client or topic (
STATE_FILE = "seen_nonprofit_housing.json") so separate alerts don't share history. -
Deadline-first view: keep only rows with
daysLeftunder 60 and sort ascending. - Append to a Google Sheet instead of a CSV, using the Sheets API or an automation tool that watches a folder.
-
Add
closedtooppStatusesif you're doing historical research on what an agency has funded before.
Wrap-up
The whole thing is one POST endpoint, a loop for paging, a title filter to cut the noise, and a JSON file that remembers what you've already seen. Set it up once and new federal opportunities that match your filters show up on their own, with deadlines and a link to the original notice.
Grants.gov data is public, but it's still a good idea to keep request volume modest (a weekly or daily run is a handful of requests) and to always read the official notice before you apply. This post isn't affiliated with Grants.gov or the US government.
If you've built something similar, I'd like to hear how you handle forecasts that never get a close date.
Top comments (0)