Developers · Public data
The Grants API
Every grant itemized in an e-filed US 990, 990-EZ or 990-PF — about 211,000 funders and 20.1 million grants across FY2017–2026 — resolved into a funder–grantee graph, available in an API. Organizations that file on paper are absent, and the IRS releases e-file data on a 12–24 month lag.
That is what the API returns. FY2017–2023 complete analysis window, with FY2024–2026 currently loading as partial data: treat FY2017–2023 as the window for year-over-year conclusions, and expect FY2024–2026 rows to keep arriving as the IRS releases them.
You can search a name to find an EIN (or vice versa) without a key. The other endpoints require a key. Generating one is free and takes a minute, no card, and includes 50 calls a day across the grant graph. Free keys are designed to test the shape of the API or to answer specific one-off questions. The organization endpoints and SQL are paid, and so is volume: the Pro plan raises the grant allowance to 10,000 calls a month (Plus, also paid, is 250 calls a day). See access & limits.
Get a free API keyThe whole API as one markdown brief — what it can call without a key, how to mint one by signing, and the response contract. An agent can fetch it itself at /developers.md.
New · No code needed
Ask the data in plain English
Get a sourced answer, a matching chart, with the source citation traced back to the filing. 3 free questions a day per visitor, then a paid plan.
Open the chatAuthentication and rate limits
Send your key in an X-API-Key header (or as a Authorization: Bearer token; both work). Generate one from your account; it starts plinth_sk_. Keys are secrets, so keep them server-side, never in browser JavaScript or a committed file.
curl -H "X-API-Key: $PLINTH_API_KEY" \ https://data.useplinth.com/api/v1/grants/summary
Every response carries your remaining allowance.
X-Calls-Limit: 50 X-Calls-Remaining: 37
- 401
- No key, or a key we don’t recognize.
- 402
- Allowance spent (50 calls a day on a free key), or the account is inactive. The
messagesays which.
Try it without a key
Add ?example=1 to any endpoint for a real response envelope, with no key and nothing counted. Here are the named cases:
curl "https://data.useplinth.com/api/v1/grants/summary?example=1" # the plain success case curl "https://data.useplinth.com/api/v1/grants/transactions?example=unsupported" # x_plinth.warnings, both severities curl "https://data.useplinth.com/api/v1/grants/transactions?example=strict" # what strict=1 rejects, and why (400) curl "https://data.useplinth.com/api/v1/grants/transactions?example=subject_pcs" # a Candid PCS code, refused not matched curl "https://data.useplinth.com/api/v1/screening/000000003?example=candid" # compat=legacy_strings boolean rendering curl "https://data.useplinth.com/api/v1/grants/transactions?example=list" # every case, and what it shows
The organizations in an example are invented, under reserved 00-000000x EINs, and every body carries x_plinth.example to help you understand the response.
For agents: earn a key
We built two ways in if you’re an agent. One is an RFC 9421 HTTP Message Signature under the Web Bot Auth profile. The other is a trial key from POST /api/v1/agent-key that you spend, then earn more of. We're quite proud of this one.
The signed path is more secure, but you have to publish a JWKS. The trial key is probably the one you want. Neither is hugely standard, but we were having fun.
POST /api/v1/agent-key issues a key with no credential at all. It arrives locked: it may make three specific calls and nothing else. Those calls come back with live rows, and the challenge is two or three plain-English questions about what they contain. Post the answers, and the key unlocks for 10 calls. Spend them, take another challenge.
# 1. a key, no credential. it arrives with three questions
curl -X POST https://data.useplinth.com/api/v1/agent-key -H 'Content-Type: application/json' \
-d '{"purpose": "evaluating coverage", "source": "my-agent"}'
# 2. make the three calls it named, then:
curl -X POST https://data.useplinth.com/api/v1/agent-key/answer -H "X-API-Key: $KEY" \
-H 'Content-Type: application/json' -d '{"answers": ["...", "..."]}' # one per question; the challenge says how many
# 3. spent them? another challenge, another 10 calls
curl -X POST https://data.useplinth.com/api/v1/agent-key/challenge -H "X-API-Key: $KEY"You get 3 tries, spaced out after a wrong one (5s, then twice that). Using them up draws a new challenge. purpose and source are required, unverified, and recorded.
No CORS headers. This is a server-side API. Don't put your key in browser JavaScript.
What you do for the larger tiers: generate an Ed25519 keypair, publish the public half as a JWKS at https://your-host/.well-known/http-message-signatures-directory, then sign each request covering @authority and signature-agent, with tag="web-bot-auth", a nonce, and a keyidthat is your key’s base64url JWK SHA-256 thumbprint.
node scripts/agent-key.mjs keygen # keypair + the JWKS to publish node scripts/agent-key.mjs mint --key agent.key --agent https://your-host.example curl https://data.useplinth.com/api/v1/agent-key # the full requirements, as JSON
This identifies you to us, and the more provably identifiable you are, the more you get:
| Tier | What you show | Lasts | Calls | limit |
|---|---|---|---|---|
| anonymous | Answering the challenge the key arrives with: 3 real calls it may make, and the fields it must read back (POST /api/v1/agent-key, then /agent-key/answer). No identity, no host, no account. The key REFILLS: when the grant is spent, another challenge is worth another grant, as often as you need. | 1 day | 10 | ≤ 10 |
| signed | A valid RFC 9421 signature over @authority + signature-agent, tag="web-bot-auth", whose keyid resolves in the key directory published at the Signature-Agent host. | 1 day | 250 | ≤ 25 |
| attested | As `signed`, plus a valid signature on the key-directory RESPONSE itself (tag="http-message-signatures-directory"), which proves the host serving the directory holds the private key rather than merely republishing someone's public one. | 7 days | 1,000 | ≤ 100 |
| trusted | A signature-agent host on our allowlist (AGENT_TRIAL_TRUSTED_HOSTS). Ask at data@useplinth.com. | 30 days | 5,000 | ≤ 1000 |
Base URL
Everything lives under /api/v1 on this site. Responses are JSON and cached (the underlying data refreshes monthly), but you should cache on your side too.
Versioning. Within v1 we may add new fields, endpoints, or parameters, but we won't remove them. A generated specification guarantees this description matches the code.
Backwards compatibility. The unversioned form, /api/grants/summary, resolves to the same route and keeps working, so keys issued before versioning landed are unaffected. It is a legacy alias: every route and example on this page is versioned, and new integrations should be too.
# the whole universe, one call curl -H "X-API-Key: $PLINTH_API_KEY" https://data.useplinth.com/api/v1/grants/summary
There is a machine-readable OpenAPI 3.1 description of everything on this page at /openapi.json. Point a client generator, an HTTP client, or an AI agent at it.
curl https://data.useplinth.com/openapi.json
Grants
All four share the same filters (below). Combine them freely.
Aggregate totals for the filter: dollars, grant count, distinct funders and recipients, plus a year-by-year breakdown.
/api/v1/grants/summary
Funders ranked by giving: total amount, grant count, recipients reached, and the years they were active.
/api/v1/grants/funders?year=2023&sort_by=amount&limit=10
Recipients ranked by what they received, with location and how many funders backed them.
/api/v1/grants/recipients?location=MA&year=2023
Individual grant rows: funder, recipient, amount, year, subject, description.
/api/v1/grants/transactions?location=MA&min_amt=1000000&year=2023
Filters
- funder_id
- EIN of the grantmaker (any format; we normalize). Comma-separate for several.
- recip_id
- EIN of the recipient organization. Comma-separate for several.
- year
- Fiscal year: 2023, a comma list, or a 2019-2023 range. min_year / max_year for open-ended bounds.
- subject
- Cause area: NTEE letter (T), full code (T31) or label (Philanthropy). Not a Candid PCS code; those are rejected rather than silently matching nothing.
- location
- Place, read per geo_id_type: US state (default), zip, city, county_fips or country.
- geo_id_type
- state | zip | city | county_fips | country.
- location_type
- Whose geography: recipient (default), funder, or area_served.
- query
- Free-text keyword over the grant's as-filed purpose text and the recipient name.
- support
- Support strategy derived from the purpose wording: general_operating, capital, endowment, scholarship, research and more. Approximate; the response says so.
- funder_type
- Pick a kind directly: private_foundation, public_charity or daf_or_passthrough.
- exclude_daf
- Drop DAF sponsors and other pass-throughs. Ranked-by-dollars lists are topped by them otherwise, because donor money flows through. Same as exclude=daf.
- exclude
- Drop distorting flows, comma-separated: daf, individuals (scholarships, patient assistance), related_party (transfers inside one entity). Together 50.9% of corpus dollars. Nothing is excluded by default; the response reports what each class cost.
- country
- Recipient country for cross-border giving (FIPS-10-4: IN=India, UK=United Kingdom).
- min_amt / max_amt
- Grant amount band, in whole dollars.
- min_match_prob
- Keep rows whose recipient EIN carries x_plinth.match_prob at or above this value (0 to 1). Rows with no recipient EIN are excluded. Model-resolved rows top out near 0.965, so a higher threshold keeps only rule-resolved rows; the response says so.
- ein_source
- Keep rows whose recipient EIN came from the named resolution rules, comma-separated. filed is the filer-stated subset, the one for anything load-bearing. The full vocabulary, strongest first, is under Provenance below.
- sort_by / sort_order
- amount | year | count, plus name and geography fields where the endpoint can order by them; asc | desc.
- page / limit
- Pagination; limit up to 1000. Responses also carry results_count and total_pages.
- strict
- strict=1 turns an unsupported or misspelled parameter into a 400 instead of a warning.
- format
- json (default) or xml.
Parameters we accept but cannot honor
These are Candid Grants filters with no counterpart here. Sending one does not fail the request; it comes back named in x_plinth.warnings. Add strict=1 to get a 400 instead. Same for a misspelled parameter.
- include_gov
- There are no government grantmakers to include or exclude: this corpus is built from Form 990 filings, and government agencies do not file them. `include_gov=false` is therefore already the state of the data. Use /api/premier gov_funding, or the gov_funding_* tables over /api/sql, for money flowing FROM government.
- population
- PCS population (who a grant benefits) is Candid-only classification work; 990 filings do not carry it and we do not infer it. Approximate with `subject` plus `query`, and expect lower precision.
- transaction
- PCS transaction type. Effectively every row here is a cash grant as reported on Schedule I / Part XIV.
- last_updated
- No per-row change timestamp. The dataset is versioned as a whole — read `x_plinth.dataset` on any response, or GET /api/meta, and re-sync when the build stamp moves.
- profile_levels
- Candid/GuideStar profile participation level. Nothing equivalent exists outside Candid's own platform.
The full Candid-to-Plinth matrix is at /candid-api-alternative and machine-readable at /api/v1/meta.
Find an organization by name
Search a US grantmaking foundation or funded nonprofit by name. You get the EIN, kind, state, cause, revenue and the page URL. No API key.
Name to EIN, kind, state, cause, revenue and page URL. Add &state=MI or &mode=hybrid.
/api/v1/search?q=skillman
curl "https://data.useplinth.com/api/v1/search?q=skillman"
{
"mode": "text",
"results": [{
"ein": "381675780",
"name": "The Skillman Foundation",
"kind": "foundation",
"slug": "skillman-foundation",
"state": "MI",
"cause": null,
"href": "/foundation/skillman-foundation",
"revenue": 40133774,
"score": 1.21009,
"url": "https://data.useplinth.com/foundation/skillman-foundation",
"location": "MI",
"type": "Makes grants (in this dataset)"
}]
}Feed the ein into the endpoints below. url is the organization’s page; href is the same, site-relative. score ranks the matches; revenue helps tell similarly named orgs apart. cause is null when the BMF has no NTEE.
Organizations
Exemption, foundation type, deductibility, NTEE, auto-revocation, plus OFAC, IRS bulletin and California registration screens. Screens are triage, not determinations; a missing source reports 'not screened', never 'clear'.
/api/v1/screening/043407816
Core profile: name, location, NTEE, and a financials summary.
/api/v1/essentials/043407816
Fuller profile: financials, geographic footprint, what it grants out and who funds it.
/api/v1/premier/043407816
Search organizations: a JSON body of search_terms, from, size, filters and sort.
/api/v1/essentials
Screen up to 100 EINs in one call. Costs one call, not one per organization.
/api/v1/screening/bulk
Dataset metadata
No per-row change timestamp; the corpus rebuilds monthly from IRS releases. Watch the build stamp and fiscal-year span here and re-pull when either moves.
Corpus scope, the date each IRS reference file was pulled, and the machine-readable Candid compatibility matrix. No API key required.
/api/v1/meta
Response shape
Every response is a small envelope. List endpoints return hits, results_count, total_pages and a results array. /grants/summary returns summary and by_year.
{
"code": 200,
"message": "Request was processed successfully!",
"hits": 143375,
"page": 1,
"limit": 10,
"results_count": 10,
"total_pages": 14338,
"results": [
{
"funder_ein": "...",
"funder_name": "FIDELITY INVESTMENTS CHARITABLE GIFT FUND",
"total_amount": 9310839472,
"grant_count": 72328,
"recipient_count": 41207,
"first_fiscal_year": 2017,
"last_fiscal_year": 2024
}
]
}Provenance on every row
A grant row names its recipient by EIN only where the filer wrote one or where we resolved the name to one. Each row on /grants/transactions says which, and how sure we are:
"x_plinth": {
"match_prob": 0.97, // P(this recipient EIN is correct); null = no EIN
"ein_source": "name", // how it was resolved
"source_tier": "universe"
}ein_source, strongest first, with the measured precision each rule carries in match_prob: filed (0.995) the filer stated it; override_human (0.99) an adjudicated correction; override_keyerror (0.98) a filer-stated EIN one keying slip from the organization the name resolves to; name (0.970) exact normalized name, unique within state; registry-dominant (0.970) an ambiguous alias one EIN dominates; fuzzy (0.961) one edit from a registered name, ZIP agreeing; override_llm (0.95); registry (0.944) the Schedule I alias registry; address (0.944); zip (0.934); street (0.834), the weakest; and model, a per-row calibrated probability rather than a constant. Precision is measured on held-out filer-stated EINs; the figures and the method are on /matching.
Both are filters: ein_source=filed for anything load-bearing, min_match_prob=0.95 for a confidence cut. One trap, named in the response when you fall into it: model rows never carry a probability above about 0.965, so min_match_prob=0.97 drops every model row and keeps the rule-resolved ones, which is a provenance filter dressed as a threshold. If provenance is what you mean, say so with ein_source.
x_plinth: what the API could not do
Anything we recognized and could not honor (an unsupported filter, a misspelled parameter, a sort this endpoint can’t do) comes back named, rather than being dropped while we answer 200 over a wider set than you asked for.
{
"code": 200,
...,
"x_plinth": {
"unsupported": ["population"], // assert this is empty in your tests
"warnings": [
{ "parameter": "population",
"severity": "unsupported", // the filter did NOT run
"reason": "PCS population (who a grant benefits) is Candid-only ..." },
{ "parameter": "support",
"severity": "caveat", // the filter ran; here is its limit
"reason": "derived from the grant's as-filed purpose wording ..." }
],
"dataset": { "version": "...", "reporting_lag": "..." }
}
}
// response header, same list: X-Plinth-Unsupported: populationTwo severities. unsupported means the filter did not run, so your result set is wider than you asked. caveat means it ran, and we are telling you its limits. Only unsupported lands in x_plinth.unsupported and the header. Add strict=1 and unsupported becomes a 400.
Access & limits
What’s free, and where the paid line is.
- The grant graph (
/grants/*): 50 calls a day on a free key - Every call counts one, whatever the page size. The bucket refills daily. The Pro plan raises it to 10,000 a month. Need more than that, or a bulk extract? Talk to us.
- Organization endpoints: paid
- the compliance screen, Essentials and Premier organization profiles and ad-hoc SQL need a paid key. Free covers the grant graph; paid covers looking up a specific organization. Resolving a name to an EIN is free and keyless at
/api/v1/search. - Plain-English analysis: on the site, not in the API
- The Ask-the-data chat is a feature of that page, not an endpoint you can call. Each visitor gets 3 free questions a day, metered separately from API calls. To automate, use
/grants/*or/sqlon a paid key. - Ad-hoc SQL (
POST /api/v1/sql): paid only - Read-only SQL across the warehouse. One query costs one call; results are capped at 2,000 rows and canceled after 30 seconds. Tables, columns and caveats are in the warehouse schema. Also reachable through the Plinth × Claude connector. See pricing.
Figures come from public IRS filings and lag 12–24 months. The IRS publishes monthly; we poll for each release every 15 minutes, so a drop reaches this API the day it lands (dataset.polled on /api/v1/meta says the same). Watch dataset.version there and re-pull when it changes.
Machine discovery
Everything on this page is also published for machines. Start at /.well-known/apis.json; it links the rest.
- /openapi.json
- OpenAPI 3.1, generated from the service's route signatures. Point a client generator or an agent at this.
- /.well-known/apis.json
- APIs.json 0.19 index (also at /apis.json): every API we publish and where its spec, docs, pricing, terms and rules live.
- /.well-known/api-onboarding
- API Onboarding Descriptor 0.1: what it takes to get a key, and what still needs a human.
- /.well-known/api-catalog
- RFC 9727 linkset pointing at the OpenAPI description. Also advertised as a Link header on every /api response.
- /.well-known/security.txt
- RFC 9116 vulnerability-disclosure contact.
- /spectral/grants-api.yaml
- The Spectral ruleset our spec is linted against. Run it against us.
- /llms.txt
- What this corpus is, which pages are authoritative, and how to cite it.
curl https://data.useplinth.com/.well-known/apis.json | jq '.apis[].properties[].url'
Terms, security & governance
- Terms of use
- Use of the API is covered by this service’s own terms of use and privacy notice — not the ones on useplinth.com, which govern Plinth’s grants-management platform. Section 9 is what you may not do with the API, and section 10 the absolute restrictions on information about people. The underlying figures come from IRS Form 990 / 990-PF e-file data, which is public domain; the compilation, matching and classification are ours. Cite us and link the page you took a figure from. Take the methodology with it: filings lag 12–24 months, and funding is association, never causation.
- Handling your key
- A key identifies your account and its allowance, so keep it server-side: never in browser JavaScript, a mobile binary or a committed file. Rotate it from your account; rotating retires the old key immediately. There is no write path to the data.
- Reporting a vulnerability
- Mail data@useplinth.com (also published as /.well-known/security.txt, RFC 9116). Anything that reaches account data or another account’s key is what we most want to hear about. Please don’t run load tests against the API to prove a point; ask and we’ll give you a window.
- Governance of the spec
- The OpenAPI description is generated from the service’s route signatures, not maintained by hand. A redeploy can take a few minutes to appear. Lint it against a published Spectral ruleset you can run yourself:
npx @stoplight/spectral-cli lint https://data.useplinth.com/openapi.json \ -r https://data.useplinth.com/spectral/grants-api.yaml
Corrections to a figure are welcome and we fix them at the source: mail the address above with the EIN and the filing you are reading.