Skip to content
Plinth

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 key

The 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 chat

Authentication 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 message says 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:

TierWhat you showLastsCallslimit
anonymousAnswering 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 day1010
signedA 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 day25025
attestedAs `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 days1,000100
trustedA signature-agent host on our allowlist (AGENT_TRIAL_TRUSTED_HOSTS). Ask at data@useplinth.com.30 days5,0001000

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.

GET/grants/summary

Aggregate totals for the filter: dollars, grant count, distinct funders and recipients, plus a year-by-year breakdown.

/api/v1/grants/summary

GET/grants/funders

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

GET/grants/recipients

Recipients ranked by what they received, with location and how many funders backed them.

/api/v1/grants/recipients?location=MA&year=2023

GET/grants/transactions

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.

GET/search

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

GET/screening/{ein}

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

GET/essentials/{ein}

Core profile: name, location, NTEE, and a financials summary.

/api/v1/essentials/043407816

GET/premier/{ein}

Fuller profile: financials, geographic footprint, what it grants out and who funds it.

/api/v1/premier/043407816

POST/essentials

Search organizations: a JSON body of search_terms, from, size, filters and sort.

/api/v1/essentials

POST/screening/bulk

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.

GET/meta

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: population

Two 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 /sql on 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.