DEV Community

Open Data Labs
Open Data Labs

Posted on AI-assisted

Get new federal grant opportunities delivered weekly with the Grants.gov API and about 100 lines of Python

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}'
Enter fullscreen mode Exit fullscreen mode

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"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

(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"))}
Enter fullscreen mode Exit fullscreen mode

(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())
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Second run, right after:

$ python3 grant_watch.py
Nothing new (15 matching opportunities already seen).
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 daysLeft under 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 closed to oppStatuses if 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)