Reading the catalogue from your own code
Creating an API key, making your first request, and the one convention that will break a client written on habit.
The API is a read-only JSON copy of the catalogue: records, grantmakers, recipients, and a feed of what has changed. It is part of Studio.
The data is not. Every field the API returns is one the site publishes for free on the corresponding page, source link and check date included — the API is a convenience for machines, not a paywall around facts.
Creating a key
Go to Settings → API keys and choose Create API Key.
Give it a name that says where it will run — nightly-mirror,
dashboard-staging — because that name is the only thing you will have to go on
when you decide which of three keys is safe to revoke.
The key is shown once. Only a hash of it is stored, which means nobody here can read it back to you either. Copy it into wherever it belongs before closing the dialog.
A Studio account may hold three keys at a time. Three, so that each environment can carry its own and any one of them can be rotated without a gap. When the table is full the Create button is disabled and the line beside it says so; revoke one to make room.
Your first request
Send the key as a bearer token:
curl -H "Authorization: Bearer $GRANTTROVE_KEY" \
"https://granttrove.com/api/v1/grants?state=CA&limit=5"The filter names are the ones the catalogue page uses, and the rows
come back in the same order that page shows them. A URL you are looking at in
the browser answers as an API call with /api/v1 in front of it.
Every response has the same three keys:
{
"data": [ ... ],
"next_cursor": "eyJvIjoyNSwidCI6MTc1...",
"as_of": "2026-09-07T12:35:00.000Z"
}as_of is when we read the database. It is not the same fact as
last_verified_at, which travels on each record and says when we last read the
funder's own page. Keep both.
The one thing to get right
Null means the source did not say. It never means zero, never means an empty list, and never means false.
amount_max: nullmeans the source stated no ceiling. A zero would be a number you would go on to average.match_required: nullmeans the source said neither way. Reading it asfalsetells an applicant no match is needed, and that can cost them a filing.elig_states: nullmeans no stated geographic restriction. An empty array would read as "no state qualifies", which is the opposite claim.
Two money figures never share a label either. amount_max is what one applicant
may receive; amount_total_pool is what the whole programme has to give away. On
a grantmaker, giving_total is unusable without giving_data_year, and the two
always travel together.
Working through a long list
Follow next_cursor until it comes back null. Stop on null rather than on a
short page — a short page is not the end.
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
doneThe cursor pins the clock as well as the position. 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 walk is computed against the instant the first page was.
That instant is the as_of on every page of it.
Keeping a copy in step
Once you hold a copy, /api/v1/changes is how you keep it current:
curl -H "Authorization: Bearer $GRANTTROVE_KEY" \
"https://granttrove.com/api/v1/changes?since=2026-09-01"It carries records added, records the sweep closed, and deadline, amount and title moves — the same data the change log publishes.
observed_at is when we saw the change. A funder can move a deadline in
March and we can re-read the page in April; no source publishes the first date,
so the field is named for the one we actually have. Do not present it to your own
readers as the date the change was made.
Ask for a window and you get all of it or a refusal. A window holding more
changes than one walk will serve comes back as window_too_large rather than as
a silent prefix: rows you never hear about look exactly like rows that never
changed, and that is the one error a mirror cannot detect for itself.
Being polite to the API
Each key may make 5,000 requests a day. The remainder comes back on every
response in X-RateLimit-Remaining.
Responses carry an ETag. Send it back as If-None-Match and an unchanged
resource answers 304 with no body, which is most of what a polling client
costs. Responses may be cached for five minutes.
When something is refused
Every refusal is JSON with a stable error slug. Branch on the slug; the
sentence beside it is for your log.
401 and 403 are different answers and it is worth handling them apart. A
401 means the credential is not valid — wrong string, or revoked. A 403 means
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.
The full reference
The developer page lists every endpoint and every parameter, and
openapi.json describes them in a form a code generator
can read. That document needs no key.