Nonprofit Grants.gov Database, Status Eligibility Award Details
Pricing
from $1.50 / 1,000 opportunity (enriched)s
Nonprofit Grants.gov Database, Status Eligibility Award Details
Search and enrich US federal grant opportunities from the official Grants.gov API: keyword, agency, status, eligibility and funding-category filters, plus award ceiling/floor, eligibility text and full synopsis via detail lookup. $0.0015/enriched result, $0.0007 for thin rows, no start fee.
Pricing
from $1.50 / 1,000 opportunity (enriched)s
Rating
0.0
(0)
Developer
Fetch Smith
Maintained by CommunityActor stats
0
Bookmarked
1
Total users
1
Monthly active users
5 hours ago
Last modified
Categories
Share
Nonprofit Grants.gov Scraper – Status Eligibility Award Details
Search US federal grant opportunities from Grants.gov's official public API — no API key, no login, no proxy. Filter by keyword, agency, status, eligibility, funding category and instrument, and optionally enrich each result with award ceiling/floor, eligibility text, funding instrument/category and the full synopsis.
What it does
- Calls Grants.gov's own
search2/fetchOpportunityendpoints (the same API that powers grants.gov/search-grants), not HTML scraping. - Search results alone carry only 10 thin fields (id, number, title, agency, dates, status). Turn on
enrich(default) to join each row with a second call for the money fields a grant seeker actually decides on:awardCeiling,awardFloor,applicantEligibilityDesc,applicantTypes,fundingInstruments,fundingActivityCategories, and the full synopsis text. - Forecasted opportunities (
docType: "forecast") are enriched too, not just posted ones — roughly half of the defaultoppStatusesresult set. Grants.gov gives a forecast its own estimated award ceiling/floor, applicant types and funding instruments/categories under the same field names as a posted synopsis, plus forecast-only fields:numberOfAwards,estimatedFunding,estSynopsisPostingDate,estApplicationResponseDate,estAwardDate,estProjectStartDate,fiscalYear. - The announcement documents come with the row. Every enriched opportunity carries an
attachmentsarray of the files the agency attached — the full NOFO PDF/DOCX, special notices, Q&A and amendment documents — each with a directdownloadUrl, plusfileName,description,mimeType,sizeBytes,folderName,folderTypeandpostedDate. Measured on a live 20-row sample: 8 rows carried 45 files between them, while Grants.gov's separate "related documents" list (synopsisDocumentURLs) was populated on only 1 of the 20 — so the attachments are where the actual announcement lives. The download links are plain public URLs (no login, no session) and are not fetched during the run, so they cost you nothing extra. - Agency codes are resolved and expanded, not passed through blind: Grants.gov's parent agency codes (e.g.
"USDA","DOD") do not automatically include their sub-agencies in a search — unlike some other government APIs. This Actor expands a parent code you supply into all of its real sub-agency codes (e.g."USDA"→USDA-NIFA,USDA-FS,USDA-APHIS, …) so filtering by department actually works. An unrecognised code is dropped with a named warning instead of silently returning zero rows. - Opportunity-number lookup ignores your other filters. Grants.gov ANDs
oppNumwith every other filter, including its own default status filter — looking up a closed or archived opportunity by its exact number normally returns nothing. SetoppNumand this Actor searches all statuses and ignores keyword/agency/eligibility filters, so an exact-number lookup always finds the opportunity if it exists. oppNums— batch-lookup a whole list of opportunity numbers in one run. Grants.gov's API has no batch or joined form for this ("num1|num2"and"num1,num2"both return zero results, verified live) so this Actor makes one exact-match lookup per number instead — same all-statuses behaviour as a singleoppNum. A number that doesn't match anything is named in a warning rather than silently dropped, so a partial miss on a long list is never invisible.postedWithinDaysfor cheap incremental pulls — Grants.gov's own "Posted Date" filter accepts any positive number of days, not just its site's 3/7/14/21-day preset buttons (verified live). Use it instead of re-scanning the whole index on a daily/weekly cron.postedFrom/postedTofor a fixed calendar window — Grants.gov's API has no absolute-date filter server-side, so this Actor applies the range client-side against each row's own open date (already present on every result, no extra detail lookups needed). Use this for historical reporting ("everything posted in Q1") wherepostedWithinDays' relative-to-today window doesn't fit. If both are set,postedFrom/postedTowins andpostedWithinDaysis ignored (with a warning).closeDateFrom/closeDateTofilter on the application deadline — the question a grant seeker actually asks ("what closes in the next 30 days?") is about the deadline, not the posting date. Grants.gov's API can sort by close date but cannot filter on it, so this is applied client-side against each row's own close date (already on every thin result row, no extra lookups, no extra cost). Be aware what has no deadline: a forecast never has one (Grants.gov returns an empty close date on 100% ofdocType: "forecast"rows), and neither do rolling/continuous announcements and RFIs (~18% of posted rows on an unfiltered sample). Those are dropped by this filter and reported under their own count in the run summary, and the Actor warns up front if you leaveforecastedinoppStatuseswhile filtering on a deadline.closesWithinDays— the same deadline filter, phrased relatively. Resolves to[today, today+N]for you (same client-side mechanism and no-deadline exclusions ascloseDateFrom/closeDateTo), so a cron doesn't have to compute calendar dates each run. IfcloseDateFrom/closeDateTois also set, the absolute range wins andclosesWithinDaysis ignored (with a warning) — same precedence rule aspostedWithinDaysvspostedFrom/postedTo.minAwardAmount/maxAwardAmountfilter on award ceiling — forcesenrichon since the amount only exists in the per-opportunity detail record. Grants.gov returns award amounts as strings, and roughly a third to half of posted opportunities have no ceiling set at all (the API spells this as the literal string"none", not null or absent) — this Actor normalizes both into real numbers ornull, and the amount filter correctly drops the"none"rows rather than treating them as zero.watchLabel— only what's new since your last run. Name a saved search and every run after the first returns just the opportunities not already delivered under that label and filter combination, instead of the whole match set every time. The first run for a label is a free baseline (0 results, 0 charged); it records what already matches in a key-value store on your own Apify account, keyed by the label plus a fingerprint of your other filters, so editing a filter starts a fresh baseline instead of dumping every previously-excluded opportunity as "new". Built for a daily/weekly scheduled run.watchChanges— also catch a deadline extension, a status change, a funding-range revision, an eligibility rewrite, or a forecast turning real. Add this towatchLabeland an opportunity you already have gets re-delivered (at the normal per-row price, tagged_watchChangeType/_watchPrevious) if its closing date,docType(forecast → posted),oppStatus(posted → closed/archived),awardCeiling/awardFloor,lastUpdatedDate(Grants.gov's own "this synopsis/forecast was edited" timestamp) orapplicantEligibilityDescchanges since you last saw it — not just brand-new opportunities. The last 3 only get watched whenenrichis on (the default), since they only exist on the enriched detail record. Off by default so existing watches keep their current behaviour.- Pay per result: charged only for rows actually returned.
Use cases
- Grant-seeking pipelines — pull every open opportunity a nonprofit, university or small business is eligible for (
eligibilities+fundingCategories), already joined with award ceiling/floor so you can triage by money without a second lookup. - Daily/weekly funding alerts — run
postedWithinDays: 1on a cron and only pay for the handful of opportunities posted since yesterday, instead of re-scanning the whole index. - Deadline triage —
closeDateFrom/closeDateTo(orclosesWithinDays: 30for the same window without computing dates) to list only what a team can still realistically apply for, instead of paging through opportunities whose deadline has already passed or is a year away. - Award-size screening —
minAwardAmount: 500000to surface only large awards, ormaxAwardAmountto find the small ones a single PI can realistically manage. - Grants-landscape research — filter by
agencies(parent codes expand to every sub-agency) andpostedFrom/postedToto reconstruct a fixed historical window, e.g. everything a department posted last quarter. - Enriching an existing list — set
oppNumfor one opportunity, oroppNumsfor a whole list of numbers you already have, and get the full record back for each, even if it is closed or archived. - Deadline-amendment / forecast-to-posted alerts —
watchLabel+watchChangeson a saved search flags an agency extending a deadline or a forecast finally posting, without re-fetching and diffing the whole result set yourself. - Funding-range and eligibility-rewrite alerts — the same
watchChangesflag also catches an agency raising or lowering an award ceiling/floor, or rewriting the eligibility text, on an opportunity you already have — one Actor covers what some competitors ship as several separate single-purpose "watch" listings.
Example input
{"keyword": "cancer research","oppStatuses": ["posted"],"minAwardAmount": 500000,"maxResults": 3}
Input
| Field | Type | Description |
|---|---|---|
keyword | string | Full-text search across title and synopsis |
oppStatuses | array | forecasted, posted, closed, archived (default: forecasted + posted) |
agencies | array | Agency codes, e.g. NSF, USDA-NIFA, DOD-AMC; parent codes are expanded to sub-agencies |
eligibilities | array | Restrict to applicant types (state govt, nonprofit, small business, individuals, …) |
fundingCategories | array | Restrict to funding activity categories (Health, Education, Environment, …) |
fundingInstruments | array | Grant / Cooperative Agreement / Procurement Contract / Other |
cfda | string | Restrict to one Assistance Listing (CFDA) number, e.g. 93.859 (the dot is optional — 93859 filters identically) |
oppNum | string | Look up one opportunity by exact number — ignores all other filters |
oppNums | array | Batch form of oppNum — look up a whole list of exact numbers in one run (one lookup call each; combined with oppNum if both set) |
sortBy | string | openDate|desc, openDate|asc, closeDate|desc, closeDate|asc |
enrich | boolean | Join each row with award/eligibility/synopsis detail (default true) |
postedWithinDays | integer | Only opportunities posted in the last N days — cheap incremental pull |
postedFrom | string | Only opportunities opened on/after this date (strict YYYY-MM-DD, a bad date stops the run); overrides postedWithinDays |
postedTo | string | Only opportunities opened on/before this date (YYYY-MM-DD); overrides postedWithinDays |
closeDateFrom | string | Only opportunities whose deadline falls on/after this date (YYYY-MM-DD); excludes rows with no deadline |
closeDateTo | string | Only opportunities whose deadline falls on/before this date (YYYY-MM-DD); same exclusions |
closesWithinDays | integer | Deadline falls within the next N days from today; ignored if closeDateFrom/closeDateTo is set |
minAwardAmount | integer | Minimum award ceiling (USD); forces enrich on, excludes opportunities with no ceiling set |
maxAwardAmount | integer | Maximum award ceiling (USD); same exclusions as minAwardAmount |
maxResults | integer | Stop after this many opportunities (default 100) |
watchLabel | string | Optional. Name a saved search to get only opportunities new since your last run under that label — see FAQ |
watchChanges | boolean | Optional, requires watchLabel. Also re-deliver an already-seen opportunity if its closing date, docType, oppStatus, award ceiling/floor, last-updated date or eligibility text changed (default false) — see FAQ |
webhookUrl | string | Optional. POST a small JSON completion summary (pushed/scanned counts, dataset ID, watch new/changed counts) here when the run finishes — see FAQ |
Output (thin fields, always present)
id, opportunityNumber, title, agencyCode, agency, openDate, closeDate, oppStatus, docType, cfdaList, url, enrichment
Output (enriched fields, when enrich: true)
agencyName, agencyCode, topAgencyName, topAgencyCode, opportunityCategory, postingDate, responseDate, archiveDate, costSharing, awardCeiling, awardFloor, applicantEligibilityDesc, applicantTypes, fundingInstruments, fundingActivityCategories, synopsisText, cfdas, fundingDescLinkUrl, synopsisDocumentURLs, attachments, assistURL, lastUpdatedDate, modComments
Output (watch-mode change fields, only on a watchChanges re-delivery)
_watchChangeType (array, one or more of closeDate/docType/oppStatus/awardCeiling/awardFloor/lastUpdatedDate/applicantEligibilityDesc), _watchPrevious (object with the previous value(s) for each changed field — applicantEligibilityDesc's previous value is a fixed note, not the old text, since only a fingerprint of it is stored, not the full text)
assistURL is Grants.gov's link to an agency's ASSIST application workspace. It is carried through verbatim from the API and is almost always empty: on a 48-opportunity live sample spanning five keyword searches and both forecast and posted rows, Grants.gov returned an empty assistURL and assistCompatible: false on every single row. The field is still emitted (as null) so the row shape stays stable, but do not build on it — use url for the public opportunity page and attachments[].downloadUrl for the announcement files.
On a docType: "forecast" row, responseDate/archiveDate/applicantEligibilityDesc/fundingDescLinkUrl are null (a forecast has no firm deadline or eligibility writeup yet) and seven forecast-only fields are added instead: numberOfAwards, estimatedFunding, estSynopsisPostingDate (Grants.gov's own estimate of when the real NOFO posts), estApplicationResponseDate, estAwardDate, estProjectStartDate, fiscalYear. These are null on synopsis-based (posted/closed/archived) rows.
Sample output (one real row from the example input above)
{"id": "357002","opportunityNumber": "PAR-24-311","title": "Molecular Imaging of Inflammation in Cancer (R01 Clinical Trial Not Allowed)","agencyCode": "HHS-NIH11","agency": "National Institutes of Health","openDate": "11/06/2024","closeDate": "01/07/2028","oppStatus": "posted","docType": "synopsis","cfdaList": ["93.394", "93.395", "93.396"],"url": "https://www.grants.gov/search-results-detail/357002","topAgencyName": "Department of Health and Human Services","topAgencyCode": "HHS","opportunityCategory": "Discretionary","postingDate": "Nov 06, 2024 12:00:00 AM EST","responseDate": "Jan 07, 2028 12:00:00 AM EST","archiveDate": "Feb 12, 2028 12:00:00 AM EST","costSharing": false,"awardCeiling": 500000,"awardFloor": null,"applicantTypes": ["State governments", "Small businesses", "Independent school districts", "..."],"fundingInstruments": ["Grant"],"fundingActivityCategories": ["Education", "Health"],"synopsisText": "The purpose of this Notice of Funding Opportunity (NOFO) is to invite research grant applications (R01) for the development and use of ...","cfdas": [{ "number": "93.394", "title": "Cancer Detection and Diagnosis Research" }],"fundingDescLinkUrl": "http://grants.nih.gov/grants/guide/pa-files/PAR-24-311.html","lastUpdatedDate": "Nov 06, 2024 10:10:59 AM EST"}
Note awardFloor: null alongside a real awardCeiling — agencies often set only one of the two. Dates come back in Grants.gov's own two formats: MM/DD/YYYY on the thin search fields, and a long MMM DD, YYYY hh:mm:ss AM/PM TZ string on the enriched detail fields. Both are passed through as the API returns them.
Sample output (the attachments array, one real row)
{"id": "332894","opportunityNumber": "W911NF21S0009","title": "LPS Qubit Collaboratory (LQC)","agency": "Dept of the Army -- Materiel Command","synopsisDocumentURLs": [{ "url": "https://www.arl.army.mil/business/broad-agency-announcements/", "description": "ARO & ARL BAA SITE" }],"attachments": [{"fileName": "LQC BAA Final W911NF21S0009.pdf","description": "LPS LQC BAA","mimeType": "application/pdf","sizeBytes": 887949,"folderName": "LPS BAA","folderType": "Full Announcement","postedDate": "Apr 16, 2021 12:37:01 PM EDT","downloadUrl": "https://www.grants.gov/grantsws/rest/opportunity/att/download/306813"},{"fileName": "LQC BAA W911NF-21-S-0009-3.pdf","description": "LQC BAA W911NF-21-S-0009-3","mimeType": "application/pdf","sizeBytes": 832097,"folderName": "LQC BAA W911NF-21-S-0009-3","folderType": "Revised Full Announcement","postedDate": "Mar 18, 2026 03:45:10 PM EDT","downloadUrl": "https://www.grants.gov/grantsws/rest/opportunity/att/download/350603"}]}
This row has five attachments in total (the original BAA, a special notice, a revised announcement and two amendments) and exactly one entry in synopsisDocumentURLs — a link to the agency's own BAA page, not the announcement itself. folderType is how Grants.gov distinguishes the original from a revision (Full Announcement vs Revised Full Announcement), and postedDate tells you which revision is current. sizeBytes is the real byte size of the file behind downloadUrl. Rows with no attached files get attachments: [], never null.
Sample output (a forecast, oppStatuses: ["forecasted"])
{"id": "355824","opportunityNumber": "MP-CPI-25-001","title": "Making America Healthy Again by Addressing Dementia Disparities","agencyCode": "HHS-OPHS","agency": "Office of the Assistant Secretary for Health","openDate": "08/01/2024","closeDate": null,"oppStatus": "forecasted","docType": "forecast","cfdaList": ["93.137"],"url": "https://www.grants.gov/search-results-detail/355824","opportunityCategory": "Discretionary","costSharing": false,"awardCeiling": 600000,"awardFloor": 450000,"applicantTypes": ["State governments", "Nonprofits having a 501(c)(3) status with the IRS, other than institutions of higher education", "..."],"fundingInstruments": ["Grant"],"fundingActivityCategories": ["Health"],"synopsisText": "The Office of Minority Health announces the anticipated availability of funds for Fiscal Year (FY) 2025 ...","cfdas": [{ "number": "93.137", "title": "Community Programs to Improve Minority Health" }],"numberOfAwards": 9,"estimatedFunding": 5000000,"estSynopsisPostingDate": "Apr 14, 2025 12:00:00 AM EDT","estApplicationResponseDate": "Jun 23, 2025 12:00:00 AM EDT","estAwardDate": "Sep 15, 2025 12:00:00 AM EDT","estProjectStartDate": "Sep 30, 2025 12:00:00 AM EDT","fiscalYear": 2025}
responseDate, archiveDate, applicantEligibilityDesc and fundingDescLinkUrl are omitted above because Grants.gov has no forecast equivalent — they read null, not missing.
Privacy note: Grants.gov's detail API also carries an agencyContactName/agencyContactEmail/agencyContactPhone block and a synopsis.agencyName/agencyPhone/agencyAddressDesc block that are agency-entered free text — sometimes a department name, sometimes a named individual program officer with a direct phone and email. Because the two cases can't be told apart per row, none of those fields are ever emitted. Organisational contact info (agencyName/agencyCode from the structured agency lookup) is included instead.
Pricing
Two events, no start fee. The price follows the data, per row — you are never charged the enriched rate for a row that arrived thin.
| Event | Price | Charged when |
|---|---|---|
result (enriched) | $0.0015 per item | The row carries its full detail record: award ceiling/floor, eligibility text, funding instrument/category, synopsis |
opportunity-thin | $0.0007 per item | enrich: false, or Grants.gov has no detail record for that opportunity (some archived ones don't) |
Pricing verified live 2026-10-02 against the whole niche, not just the leader: a 15-term Store sweep finds 84 listings mention Grants.gov, and every one of them was price-checked. (An earlier version of this paragraph said 44. That was a one-search-term count; a 15-term sweep of the same Store returns 84, so the niche is roughly twice the size we previously published. The prices below are unchanged — only the denominator was wrong.) solidcode/grants-gov-scraper (8 users) prices $0.0096/result on FREE down to $0.008 on DIAMOND plus a $0.005 Actor-start fee — we undercut even its cheapest (DIAMOND) tier at our more expensive enriched rate, and charge no start fee at all. thoob/grants-gov-feed (2 users) has no enrich/thin split and bills every row at a flat $0.01, 6.7x our enriched rate and 14x our thin rate.
What we do not claim: this is a crowded niche and we are not the cheapest listing in it. 60 of the 82 listings with a comparable per-event price charge an Actor-start fee ($0.00005–$0.10) and 22 charge none, so a no-start-fee listing is a minority but not rare. Per-row prices run from $0.00001 to $15.00, and 13 of those 82 match or beat our $0.0015 enriched rate — among them hridayrungta/grants-gov-scraper and andrew_avina/grants-mcp at $0.0015 with no start fee, ayush.naa/grants-fit-deadline at $0.001 with no start fee (cheaper than our enriched rate at every volume), and shahidirfan/Grants-gov-Scraper, springlike_meadowland/us-grant-opportunities-scraper, chorelet/government-tenders-scraper and jungle_synthesizer/grants-gov-crawler at $0.001/row behind a start fee.
Two listings are also cheaper than our $0.0007 thin rate, which an earlier version of this paragraph wrongly said none were. fiery_dream/scholarship-intel (39 users — the niche's biggest listing by lifetime users) charges $0.00005 Actor-start plus $0.00001/result and has a search_type: "grants" ("Federal Grants Only") mode reading Grants.gov, so it is cheaper than our thin rate from the first row: a 100-row pull costs about $0.0011 there against $0.07 at our thin rate. It is a student-facing matcher, not a grants feed — its inputs are GPA, degree level, field of study and first-generation status, with no agency, status, posted-date or Assistance Listing (CFDA) filter, no enrich/thin split and no watch mode. alizarin_refrigerator-owner/grants-gov-api---federal-grant-opportunities (7 users) is a genuine Grants.gov API wrapper with agency/category/eligibility/award-range filters and a webhook, and its per-row rate is also $0.00001 — but it bills a $0.10 Actor-start fee plus $0.01 per operation, so a 100-row search costs about $0.111 there: it beats our enriched rate above roughly 74 rows per run and our thin rate only above roughly 160 rows, while we are cheaper below that. Neither ships the enrich/thin split itself (you are never charged the enriched rate for a row that arrived thin) or the watch/change-detection and CFDA-validation behaviour documented above. Price-shop on the feature list and on your rows-per-run, not on the headline rate. Pricing verified live 2026-10-02.
The run log prints the split (Charged N as enriched "result" and M at the cheaper "opportunity-thin" rate) so the invoice is checkable against the dataset.
FAQ
Do I need a Grants.gov account or API key?
No. This uses Grants.gov's own public search2/fetchOpportunity endpoints — no key, no login, no proxy.
Does maxResults count rows before or after the filters?
After. It caps the number of opportunities actually returned to you, which is also the number you are charged for. Verified live: keyword: "cancer research", oppStatuses: ["posted"], minAwardAmount: 500000, maxResults: 3 returned exactly 3 rows, all with an award ceiling of $500,000 or more — not 3 scanned rows of which some survived.
Why does an opportunity have awardCeiling: null?
Because the agency never set one. Grants.gov spells this as the literal string "none" in its detail record; this Actor normalizes it to null rather than passing through an inconsistently-typed string or pretending it is 0. Measured live at roughly a third of posted opportunities, so it is a common case. Note that minAwardAmount/maxAwardAmount therefore exclude these rows — there is no ceiling to compare against. This applies equally to forecasts — a forecast can have awardCeiling: null too if the agency hasn't estimated one yet — but a forecast is never excluded just for being a forecast; its detail record is fetched and its awardCeiling compared the same as any posted opportunity's.
Does the award-amount filter work on forecasted opportunities, or only posted ones?
Both. Every docType:"forecast" row gets the same detail lookup as a posted one, and Grants.gov gives forecasts their own awardCeiling/awardFloor estimate under the same field names — so minAwardAmount/maxAwardAmount compare against it identically. (Fixed cycle 325: earlier builds silently treated every forecast as having no detail record at all, so minAwardAmount/maxAwardAmount dropped 100% of forecasts regardless of their real award ceiling. If you were filtering by amount before and never saw a forecast in your results, that's why — re-run now.)
I filtered by "USDA" — do I get the sub-agencies too?
Yes. Grants.gov's own API does not do this: a parent code matches nothing but itself, so a plain "USDA" search on the raw API returns almost nothing. This Actor expands the parent into its real sub-agency codes first. Verified live: agencies: ["USDA"] returns rows with agencyCode values like USDA-NIFA and USDA-APHIS. An unrecognised code is dropped with a named warning in the log instead of silently returning zero rows.
Can I look up a closed or archived opportunity by its number?
Yes, and you do not need to change oppStatuses to do it. When oppNum is set, this Actor searches all four statuses and ignores every other filter. Verified live: oppNum: "USDA-NIFA-BFR-002918" with the default statuses (forecasted + posted) and a deliberately unrelated keyword: "quantum physics" still returned that one archived opportunity.
Can I look up more than one opportunity number at once?
Yes — set oppNums (array) instead of, or alongside, oppNum. Grants.gov's API has no batch or joined form for this param (verified live: a pipe- or comma-joined value like "num1|num2" returns zero results, not two), so each number gets its own exact-match lookup call, same all-statuses behaviour as a single oppNum. If a number in the list has no match, it is named in a warning rather than silently missing from the output.
What's the difference between closesWithinDays and closeDateFrom/closeDateTo?
Same filter, two ways to express it. closesWithinDays: 30 resolves to today through 30 days from now at run time, so a scheduled cron doesn't need to compute a fresh calendar date every time it runs — same tradeoff as postedWithinDays vs postedFrom/postedTo. Set closeDateFrom/closeDateTo instead for a fixed window (e.g. a specific fiscal quarter) that shouldn't shift with the run date. If both are set, the absolute range wins and closesWithinDays is ignored, with a warning in the run log.
What happens if I typo one of the date filters?
The run stops immediately with an error naming the bad value, before anything is fetched or charged. Only strict YYYY-MM-DD is accepted and it has to be a real calendar date, so 2024-02-30, 2024-13-01, 06/15/2024 and 2024-6-5 are all rejected rather than guessed at. This applies to postedFrom, postedTo, closeDateFrom and closeDateTo, and an unusable postedWithinDays/closesWithinDays day count ("seven", 0, -5) stops the run the same way. It is deliberate: an unparseable bound used to be dropped with a warning, which turned "posted in Q1" into "posted at any time" — a larger, wrong, fully billable result set with a completely normal-looking run log. Precedence between a valid relative window and a valid absolute range is unchanged: the absolute range still wins, with a warning, because both were things you asked for.
Why did my run return zero results?
Every filter is ANDed, and Grants.gov's API never reports a bad value — a typo'd code returns "success" with zero hits. Most common causes, in order: oppStatuses defaults to forecasted + posted, so history needs closed/archived added; a narrow keyword plus agency plus eligibility often genuinely has no matches; a small postedWithinDays/postedFrom window is a hard filter; and the award-amount filters drop every row with no ceiling set. The run log names which one applied.
Should I turn enrich off?
Only for fast sweeps where the thin fields (id, number, title, agency, dates, status, CFDA list, plus a URL this Actor builds for you) are enough — those rows are billed at $0.0007 instead of $0.0015, because they cost no detail lookup to serve. Everything a funding decision actually turns on — award amounts, eligibility text, funding instrument/category, the full synopsis — exists only in the detail record, which is why enrich defaults to on. It is forced on when you set an award-amount filter.
I left enrich on but some rows came back without award amounts — was I charged full price for them?
No. Grants.gov has no detail record at all for a small number of opportunities (mostly archived ones with no synopsis or forecast record). When the detail lookup comes back empty, the row is still returned with its thin fields and billed as opportunity-thin ($0.0007), not result ($0.0015). The split is printed in the run log at the end of every run. Every row also carries an enrichment field saying which of these happened — see the next question, because "Grants.gov has no detail record" and "Grants.gov did not answer us" are not the same thing and used to look identical.
How do I tell "this opportunity has no attachments / no award ceiling" from "you failed to fetch them"?
Read the row's enrichment field. It is one of four values:
enrichment | What it means for the enriched fields on that row |
|---|---|
ok | The detail record was fetched and merged. An empty attachments really is no attachments; a null awardCeiling really is an agency that set none. |
not-requested | You ran with enrich: false. No detail lookup was made, so none of the enriched fields are present. |
no-detail-record | Grants.gov answered and has no synopsis or forecast record for this opportunity (mostly archived ones). The enriched fields genuinely do not exist upstream. |
fetch-failed | Grants.gov did not answer the detail lookup (retries exhausted, 5xx, or a non-JSON body). The enriched fields may well exist — we could not ask. Re-run to get them. |
Only ok licenses you to treat a missing value as a fact about the grant. This matters most with minAwardAmount/maxAwardAmount: a row whose detail lookup failed has no ceiling to compare, so it is dropped — but it is counted and reported separately from rows the agency genuinely left open-ended, and the run log names the count.
Was my result set complete? (RUN_SUMMARY)
Every run writes a RUN_SUMMARY record to its own key-value store — no webhook needed:
GET https://api.apify.com/v2/actor-runs/<runId>/key-value-store/records/RUN_SUMMARY
{"declaredMatches": 2113,"scanned": 1000,"delivered": 100,"complete": false,"incompleteReason": "max-results","incompleteDetail": "Stopped at maxResults=100; matching opportunities past this point were not returned.","mode": "search","enrichedCharged": 98,"thinCharged": 2,"detailFetchFailures": 0,"detailNoRecord": 2,"droppedNoAward": 0,"droppedUnknownAward": 0,"republishedRowsDropped": 0,"notFoundOppNums": [],"failedOppNums": [],"baselineSize": null,"baselineTruncated": null,"baselineTruncatedTotal": null,"watchChangeBlindFilters": null}
declaredMatches is Grants.gov's own count of everything matching your filters, so delivered is checkable against it from code rather than by reading English in a log. complete is deliberately separate from any status string: a run can succeed and still be truncated, and that is exactly the case this record exists to make machine-readable. incompleteReason is one of max-results (your own cap — benign), charge-limit (the run's maximum-cost limit stopped it), seed-cap (a watch baseline hit the 20,000-opportunity cap), or search-request-failed (Grants.gov stopped answering mid-walk — the result set is short through no choice of yours, and before this existed that failure ended the paging walk looking exactly like a finished run). When the run is incomplete the Actor also sets a run status message saying so. baselineSize/baselineTruncated/baselineTruncatedTotal (watch mode only) report the current baseline size and the "Baseline size cap" defect above — see that FAQ entry. watchChangeBlindFilters (null unless watchChanges is on) lists any filter in this run that narrows on a field watchChanges tracks and therefore hides those changes — empty array means nothing is blinding change detection; see "don't filter on the field you're watching" below.
I set a cfda number and got zero rows — is the number wrong, or is there really nothing?
Grants.gov can't tell you: it answers an unusable Assistance Listing number exactly the way it answers a genuinely empty search — HTTP 200, errorcode: 0, "Webservice Succeeds", zero results. So this Actor answers it for you. Whenever a cfda-filtered search declares zero matches, it re-asks Grants.gov for that same number across all four statuses with no other filter, and the log then says outright which case you're in: either the cfda matches nothing at all on Grants.gov (so the number — not your other filters — emptied the run), or it matches n opportunities and your other filters ANDed them away. The count lands in RUN_SUMMARY.cfdaMatchesAnyStatus for pipelines; null there means "not checked" (no cfda set, rows were returned, or the check itself failed) and should never be read as "the number is fine".
One honest caveat: a real Assistance Listing that has simply never been attached to a Grants.gov opportunity looks the same as a typo (10.001 is a live example). The check reports "matches nothing on Grants.gov", which is the fact that affects your result set — not "this is not a real CFDA number". Look numbers up at sam.gov/content/assistance-listings. The value you pass is always sent to Grants.gov exactly as given; nothing is dropped, normalised or guessed.
Can the same opportunity come back twice (and be charged twice)?
No — Grants.gov sometimes serves the exact same opportunity under two (or more) brand-new ids within one result set, which an id-keyed check can never catch, since the id is exactly what differs. Measured live 2026-09-24 on a 400-row unfiltered sample (oppStatuses: forecasted|posted|closed|archived, no keyword): 1% of rows were byte-identical republications (same title/agencyCode/openDate/closeDate/opportunityNumber/docType) — one Fish & Wildlife Service opportunity ("Evaluation and Improvement in Desert Bighorn Sheep Population Estimates") was posted 3 times under ids 51589/51581/51611. opportunityNumber alone is not a safe dedup key: the same sample also had 2 cases where Grants.gov reused a number for a genuinely revised posting (different title and/or open date — a real correction, not a duplicate). Every run now dedupes on a same-source content hash (opportunityNumber + title + agencyCode + openDate + closeDate + docType, requiring all six to match) before any charge, and reports how many it dropped in RUN_SUMMARY.republishedRowsDropped.
How does watchLabel know what's already new, and where is that baseline stored?
The first run for a label walks the whole match set (every page, not just maxResults of it), records every opportunity's id, and returns nothing — you are charged $0. Every later run with the same label and the same other filters returns only opportunities whose id isn't in that recorded set, then adds them to it. The baseline lives in a key-value store named fetchsmith-grants-watch in your own Apify account (Storage tab in the console), not ours — you can inspect or delete it any time. Deleting the record for a label resets it to a fresh baseline on the next run. Verified live on build 0.1.9: a seed run over keyword: "water" recorded 18,458 opportunity ids and returned 0 rows; an identical rerun returned 0 new; removing 3 ids from the baseline directly and rerunning returned exactly those 3.
Baseline size cap. A baseline holds up to 60,000 opportunity ids in one saved record. If a label's baseline grows past that, the oldest ids are dropped — and a dropped id is no longer recognised, so it comes back as "new" on a later run and is charged again. The run that drops them says so explicitly: a warning in the log, a note on the run's status message, and baselineTruncated / baselineTruncatedTotal (this run / the whole life of the label) in the saved record, on the webhookUrl payload, and in RUN_SUMMARY. If you see it, narrow the watch query (keyword, agencies, postedFrom/postedTo, eligibilities) or split it across several labels so each baseline stays under the cap.
If I change a filter, does watchLabel dump a pile of "new" results I've actually seen before?
No. The baseline key includes a fingerprint of every other filter you set, so changing keyword, agencies, postedFrom/postedTo, minAwardAmount, etc. starts an entirely fresh baseline (another free, zero-result seed run) under that label instead of comparing against the old filter's baseline. oppNum lookups ignore watchLabel entirely — an exact single-opportunity lookup has no "new since last time" to track.
What does watchChanges add, and does it cost extra to turn on?
No extra fee — a changed opportunity is billed at the same per-row price as a new one ($0.0015 enriched / $0.0007 thin), so you only pay when there is actually something to see. Plain watchLabel only ever tells you about opportunities it has never delivered before; it stays silent forever about one it already sent you, even if that agency later extends the deadline, closes it early, revises the award range, rewrites eligibility, or turns a forecast into a real posted synopsis. Set watchChanges: true and each run also compares every already-delivered opportunity's closeDate/docType/oppStatus/awardCeiling/awardFloor/lastUpdatedDate/applicantEligibilityDesc against what it looked like last time; if any moved, the row is re-delivered tagged with _watchChangeType (which field(s) changed) and _watchPrevious (what they used to be, except applicantEligibilityDesc — only an 8-character fingerprint of that text is stored, never the full text, so its "previous" value is a fixed note rather than the old wording). The award/eligibility/last-updated fields only exist on the enriched detail record, so they're only watched when enrich is on (the default) — with enrich: false, watchChanges still catches closeDate/docType/oppStatus. Verified live: seeding a baseline, editing 2 opportunities' recorded closing date and doc type directly, then rerunning returned exactly those 2 rows with the correct change tags and nothing else — and a plain unchanged rerun after that returned 0 rows again; the award-ceiling/floor, last-updated-date and eligibility-fingerprint detection was verified the same way in a follow-up test (2 more opportunities mutated on those fields, correctly and only those 2 re-delivered with the right _watchChangeType). Existing watch labels created before this feature shipped work immediately; the first run under a newly-tracked field just starts detecting drift from that point forward rather than reporting an artificial backlog.
Important: don't filter on the field you're watching for changes (oppStatuses catches most people).
Change detection can only compare an opportunity that is still in this run's match set — so a filter on a field watchChanges tracks is self-defeating: the very change you're watching for is what removes the row from view, and you never hear about it. The default oppStatuses (forecasted, posted) hits this: an opportunity that goes posted → closed drops straight out of the default match set, so the closure alert never fires. Measured against the live API on 2026-09-26 — keyword wildfire returned 21 hits under the default statuses, none of them closed, while oppStatuses: closed returned 380 completely different hits, including two that closed within the previous month (ids 363103, 363336) and would have been sitting in a month-old watch baseline. The same trap applies to closeDateFrom/closeDateTo/closesWithinDays (a deadline moved outside your window vanishes — the deadline amendments this feature exists to catch), minAwardAmount/maxAwardAmount (a revised ceiling leaves the range) and eligibilities (a rewrite drops the category).
What to do: for each field you want alerts on, widen or drop the filter on that field in the watch query and filter your own copy of the rows instead — e.g. set oppStatuses to all four (forecasted, posted, closed, archived) to catch closures, and leave the deadline/award-amount filters off the watch label. Widening costs nothing to backfill: a label's first run on a new filter set is a free baseline (0 rows charged), so every historical opportunity the wider query newly matches lands in that baseline for free and is never charged; only genuinely new and genuinely changed opportunities are billed from then on. Each run that has a change-blind filter set says so in a log warning and lists them in RUN_SUMMARY.watchChangeBlindFilters (an empty array means nothing is blinding change detection), so a scheduled caller can assert on that field before trusting a quiet "no changes this run".
How is webhookUrl different from Apify's own platform webhooks?
Apify's platform webhooks are configured separately per Task/Actor via the Console or the Webhooks API — useful if you're already living in the Apify Console, but extra setup if you're calling this Actor's API directly and just want a completion ping. webhookUrl is a plain input field: set it on the run itself and it POSTs a JSON body (actorRunId, defaultDatasetId, pushed, scanned, enrichedCharged, thinCharged, a summary object identical to the RUN_SUMMARY record described above, and — if watchLabel is set — watchNewCount/watchChangedCount) once the run finishes and every row is already pushed and charged. It's best-effort: a slow or failing webhook only logs a warning, it never fails the run, changes the result set, or affects billing.
Notes
Only public data from Grants.gov's official API is collected. Issues or feature requests: support@fetchsmith.com. Also available as a hosted API at https://fetchsmith.com
Related guides
- Grants.gov's search API has two opposite silent failures — and only one of them is safe — a typo'd filter value returns zero rows; a typo'd filter name returns the whole unfiltered catalog at the same
errorcode: 0. Measured both, and the guard this Actor now runs on every search page. - https://fetchsmith.com/blog/grants-gov-federal-grant-opportunities-json-api
- https://fetchsmith.com/blog/nih-reporter-grants-json-api
- Eight ways an "only new since last run" watch mode silently stops working — how
watchLabelis built, and why a cheap id-only baseline still has to apply the award-amount filter. - We nearly charged our own buyers twice for rows they'd already paid for — a capped watch-mode baseline can silently evict old-but-current ids on a high-volume run, re-delivering (and re-billing) rows already paid for. Reproduced on this Actor, closed with truncation tracking.
- Eight government JSON APIs that need no key — and the specific way each one lies to you — how this API's silent-failure shape compares across all eight free government JSON APIs we scrape.
Source code
https://github.com/Fetchsmith/fetchsmith/tree/main/actors/grants-gov-scraper
More tools: fetchsmith.com/tools — 19 HTTP-only Actors for public data sources, no browser required.