API
A read-only JSON copy of the catalogue: records, grantmakers, recipients, and a change feed. Every field the API returns is one the site already publishes for free, with its source link and the date we last checked it.
Getting started
Create a key under Settings → API keys, then send it as a bearer token. Keys are shown once, stored as a hash, and can be revoked from the same page.
curl -H "Authorization: Bearer $GRANTTROVE_KEY" \
"https://granttrove.com/api/v1/grants?state=CA&limit=5"Every response is the same envelope, whether it holds a list or a single record:
{
"data": [ ... ],
"next_cursor": "eyJvIjoyNSwidCI6MTc1...",
"as_of": "2026-09-07T12:35:00.000Z"
}as_of is when we read the database. It is not last_verified_at, which is when we last read the funder’s own page. Both travel with every record, and neither substitutes for the other.
What null means
This is the one convention that will break a client written on habit. Three cases are worth spelling out:
amount_maxnull means the source stated no ceiling. A zero would be a number you would go on to average, and it would drag every mean you compute towards nothing.match_requiredis a tri-state: true, false, or null. Null means the source said neither way. Reading it as false tells an applicant no match is needed, which can cost them a filing.elig_statesnull means the source stated no geographic restriction. An empty array would read as “no state qualifies”, which is the opposite claim.
Two figures never share a label. amount_max is what one applicant may receive; amount_total_pool is what the whole programme has to give away. On the funder side, giving_total is never usable without giving_data_year, and both are always present together.
Endpoints
| Method | Path | Returns |
|---|---|---|
| GET | /api/v1/grants | Records. Same filters, same order as /grants. |
| GET | /api/v1/grants/{slug} | One record. |
| GET | /api/v1/funders | Grantmakers. |
| GET | /api/v1/funders/{slug} | One grantmaker. |
| GET | /api/v1/recipients | Award recipients. |
| GET | /api/v1/recipients/{slug} | One recipient, with awards by year. |
| GET | /api/v1/changes | What moved, and when we saw it move. |
| GET | /api/v1/openapi.json | This API, described. No key needed. |
The list endpoints take ?limit= up to 100 and the filters the matching page takes. /api/v1/grants accepts entity, state, industry (each repeatable), q, status and updated_since — the same names /grants reads, returning the same rows in the same order.
A filter value the catalogue cannot answer for is dropped rather than applied, and the dropped values come back in X-GrantTrove-Ignored-Filters. Applying ?state=ZZ literally would return an empty list, which reads as “no records there” rather than “no such filter”.
Paging
Follow next_cursor until it is null. Stop on null rather than on a short page — a short page is not the end of a traversal, and null is.
cursor=""
while :; do
page=$(curl -sG -H "Authorization: Bearer $GRANTTROVE_KEY" \
--data-urlencode "limit=100" \
${cursor:+--data-urlencode "cursor=$cursor"} \
"https://granttrove.com/api/v1/grants")
echo "$page" | jq -c '.data[]'
cursor=$(echo "$page" | jq -r '.next_cursor // empty')
[ -z "$cursor" ] && break
doneA cursor pins the clock as well as the offset. The catalogue’s order depends on the time — a record drops down the list the moment its own deadline passes — so every page of one traversal is computed against the instant the first page was, and reports it as as_of. A cursor from one filter used on another is refused rather than silently answered.
Keeping a copy in step
/api/v1/changes carries what moved: records added, records the sweep closed, and deadline, amount and title changes. It is the same data /data/changes publishes.
curl -H "Authorization: Bearer $GRANTTROVE_KEY" \
"https://granttrove.com/api/v1/changes?since=2026-09-01"A window holding more changes than one traversal will serve is refused with window_too_large rather than truncated. A change feed is the one thing a client cannot check by eye: rows it never hears about look exactly like rows that never changed.
Caching and limits
Responses carry an ETag and may be cached for 300 seconds. Send the tag back as If-None-Match and an unchanged resource answers 304 — which does not spend a request against your allowance any differently, but does save you the body.
curl -I -H "Authorization: Bearer $GRANTTROVE_KEY" \
-H 'If-None-Match: "9f2c..."' \
"https://granttrove.com/api/v1/grants?limit=5"Each key may make 5,000 requests a day. The window and the remainder come back on every response as X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. They are advisory: two requests in flight together are told the same remainder, and the number that decides anything is the 429.
The limit is per key rather than per account, which is what makes three keys useful — a nightly mirror and a dashboard cannot starve each other.
Errors
Every refusal is JSON with a stable error slug and a sentence. Branch on the slug; the sentence is for your log.
| Status | error | Means |
|---|---|---|
| 400 | invalid_parameter | A parameter this API cannot act on. The body names which. |
| 400 | cursor_filter_mismatch | A cursor spent against a different filter. Start again with none. |
| 400 | window_too_large | More changes in that window than one traversal serves. Narrow it. |
| 401 | missing_key | No Authorization header. |
| 401 | invalid_key | Not a key, or one that has been revoked. |
| 403 | plan_required | A real key on an account whose plan does not include the API. |
| 429 | rate_limited | The key has spent its requests for the day. Retry-After says when. |
401 and 403 are different answers. A 401 says the credential is not valid; a 403 says the credential is real and the account’s plan does not include the API — which is what a key outliving a downgrade looks like. The plan is read on every request and is never baked into the key.
What you may do with it
Every field here is one the site publishes for free, and it comes with source_url and last_verified_at so that anything you build on it can be checked back to the page it was read from. Carry both through to whatever you publish. A figure without its date is not a figure we can stand behind, and it will not be one you can either.