Grants.gov's search page is fine for a one-off look. For "tell me when something new shows up for our topics", you want a script that runs every morning and only speaks up when something changed. Grants.gov's search2 API needs no key, so that script is short.
This one reports three kinds of change:
- NEW: an opportunity that matches your searches and wasn't there last time.
- STATUS: a forecast that's now posted, which means you can apply.
- DEADLINE: a close date that moved.
The API in one paragraph
POST https://api.grants.gov/v1/api/search2 with a JSON body such as {"keyword": "broadband", "oppStatuses": "forecasted|posted", "rows": 100, "startRecordNum": 0}. The response is {"errorcode": 0, "msg": "...", "data": {"hitCount": N, "oppHits": [...]}}. Each hit has id, number, title, agencyCode, openDate, closeDate (both MM/DD/YYYY, and closeDate can be empty), oppStatus and docType. Grants.gov's docs say no authentication is needed.
The script
import html, json, pathlib, requests
API = "https://api.grants.gov/v1/api/search2"
SEARCHES = ["broadband", "workforce training"] # one search per keyword
REQUIRE = {"workforce training": ["workforce"]} # optional: words the title must contain
STATE = pathlib.Path("grants_seen.json")
def search(keyword):
hits, start = [], 0
while True:
r = requests.post(API, json={"keyword": keyword, "oppStatuses": "forecasted|posted",
"rows": 100, "startRecordNum": start}, timeout=30)
r.raise_for_status()
body = r.json()
if body.get("errorcode") != 0:
raise RuntimeError(body.get("msg"))
page = body["data"]["oppHits"]
hits += page
start += len(page)
if not page or start >= body["data"]["hitCount"]:
return hits
seen = json.loads(STATE.read_text()) if STATE.exists() else {}
now = {}
for kw in SEARCHES:
for h in search(kw):
title = html.unescape(h["title"]) # titles can contain entities like –
if not all(w.lower() in title.lower() for w in REQUIRE.get(kw, [])):
continue
now[h["id"]] = {"number": h["number"], "title": title, "status": h["oppStatus"],
"close": h["closeDate"], "agency": h["agencyCode"]}
for oid, g in now.items():
old = seen.get(oid)
url = f"https://www.grants.gov/search-results-detail/{oid}"
if old is None:
print(f"NEW {g['status']:<10} {g['number']} {g['title'][:60]} {url}")
elif old["status"] != g["status"]:
print(f"STATUS {old['status']} -> {g['status']} {g['number']} {url}")
elif old["close"] != g["close"]:
print(f"DEADLINE {old['close'] or '-'} -> {g['close'] or '-'} {g['number']} {url}")
STATE.write_text(json.dumps(now, indent=1))
print(f"{len(now)} open or forecasted opportunities tracked")
The first run prints everything as NEW and saves grants_seen.json. After that, each run prints only the differences. Pipe the output into email or Slack, or run it from cron after breakfast.
Things I learned building it
Check errorcode, not just the HTTP status. The body carries its own status, and errorcode: 0 means success. The script raises on anything else, so a failed search can't be mistaken for "nothing new" (which would also wipe your state).
Multi-word keywords match broadly. When I tested on 22 Oct 2026, "rural broadband" returned more hits than rural or broadband on its own. Wrapping it in quotes ("\"rural broadband\"") returned nothing. The docs don't spell out how matching works, so don't rely on the search for precision. Search broadly, then narrow locally. That's what the REQUIRE map does: for workforce training, keep only titles containing "workforce". Titles are short, so a title-only filter can miss things. Loosen it if you'd rather see too much than too little.
Titles contain HTML entities. You'll see – and friends in title, so html.unescape() them before display or matching.
Forecasts are worth watching. oppStatuses: "forecasted|posted" includes forecasts, the agency's heads-up that a funding opportunity is coming. The STATUS line (forecasted -> posted) is often the most useful alert of the three, because it's the moment the application window opens.
The ID is stable, the number is for humans. Key your state on id, and use number (for example HHS-2027-…) in messages. The detail page is https://www.grants.gov/search-results-detail/{id}.
Need money and eligibility? Search hits don't include them. Call POST https://api.grants.gov/v1/api/fetchOpportunity with {"opportunityId": <id>} for each NEW hit. The award ceiling and floor, eligible applicants and agency contact sit in the synopsis object for posted opportunities, or the forecast object for forecasts. Only fetch details for what's new, to keep the request count down.
What disappears. Opportunities that close drop out of a forecasted|posted search. The script quietly forgets them. If you want a "closed" alert, compare seen keys that are missing from now.
The one-call version (disclosure: we built this)
We packaged this as a pay-per-use Apify Actor: SAM.gov & Grants.gov Scraper. It isn't an official government product and isn't affiliated with Grants.gov or any agency.
-
Live Grants.gov data, no key. It reads
search2andfetchOpportunityduring each run, for posted and forecasted grants. - Detail fields in a flat row: award ceiling and floor, estimated funding, expected number of awards, assistance listings, funding categories and eligible applicant types. Point-of-contact fields are off by default.
- Filters: keyword (via Grants.gov's own search), agency, and posted or due date, with agency and dates checked in code.
-
Only new grants. Turn on
monitorModeand schedule it daily to get only opportunities you haven't seen before. -
SAM.gov contracts too, in the same schema, if you set
opportunityTypetoboth.
from apify_client import ApifyClient
client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("yourevenings/us-gov-opportunities").call(run_input={
"opportunityType": "grants",
"keywords": ["broadband"],
"postedWithinDays": 30,
"maxResults": 0,
})
for g in client.dataset(run["defaultDatasetId"]).iterate_items():
print(g["postedDate"], g["status"], g["agency"], g["title"], g.get("awardCeiling"))
Unlike the script above, monitor mode reports new opportunities only. It doesn't report status or deadline changes.
The full reference for both endpoints (request bodies, paging, the date formats, forecast vs synopsis) is here: Grants.gov API: search federal grant opportunities from code.
Prices are on the Actor's Pricing tab.
Which agencies or topics do you watch? Tell us in the comments.
Top comments (0)