A US tax-exempt organization can leave several distinct federal records. The IRS may carry an EO account/status entry; an organization required or choosing to file may publish a Form 990; and federal awards may appear in USAspending. These records form a partial, time-bounded portrait, not a complete one. Churches can lawfully be absent from the BMF and generally need not file Form 990, so absence is never treated as evasion.
The datasets
- IRS exempt organizations — the EO account/status layer: STATUS, subsection, and IRS ruling month. A filtered recognition cohort still requires reconciliation with Pub. 78 and revocation history rather than being presented alone as current operating status.
- IRS Form 990 — where a return is required or voluntarily filed, institution-level balance-sheet and activity totals, plus Schedule I grants the organization itself makes.
- USAspending prime-award obligations and separately queried subawards, plus grants.gov — federal commitments recorded for the exact recipient or subrecipient identifier.
The keys: EIN inside IRS, UEI inside USAspending
An exact EIN connects IRS recognition and filing records that carry it. USAspending recipient search does not expose EIN; a separately verified exact UEI independently anchors the award side. The two systems should be joined only when an official source explicitly maps that EIN to that UEI. Otherwise a normalized name and location can discover a private candidate for manual review but cannot confirm identity.
# EIN is the stable key inside IRS data. USAspending does not expose EIN as
# a recipient-search field; use a separately verified UEI for an exact award lookup.
# Normalized names may discover candidates but never confirm identity by themselves.
import re
from datetime import datetime, timezone
import requests
# 1. IRS exempt organizations: recognition, subsection, and ruling month.
# Reconcile current BMF, Pub. 78, revocation history, and determination letters.
# 2. IRS Form 990: where a return is required or voluntarily filed, it can show
# institution-level financial totals and Schedule I grants made to others.
# 3. USAspending example: grant/cooperative-agreement awards to one UEI.
# It covers legacy codes 02-05 and current codes F001/F002. It excludes
# contracts, other assistance types, and all subawards.
EXPECTED_COVERAGE_NOTE = (
"For searches, time period start and end dates are currently limited to an "
"earliest date of 2007-10-01. For data going back to 2000-10-01, use either "
"the Custom Award Download feature on the website or one of our download or "
"bulk_download API endpoints as listed on https://api.usaspending.gov/docs/endpoints."
)
def usaspending_grants_for_uei(uei):
normalized_uei = re.sub(r"[^A-Z0-9]", "", uei.upper())
if not re.fullmatch(r"[A-Z0-9]{12}", normalized_uei):
raise ValueError("UEI must contain exactly 12 letters or digits")
acquired_at = datetime.now(timezone.utc)
coverage_start = "2007-10-01"
coverage_end = acquired_at.date().isoformat()
coverage_notes = None
exact_rows = []
seen_awards = set()
page = 1
while page <= 1_000:
response = requests.post(
"https://api.usaspending.gov/api/v2/search/spending_by_award/",
json={
"filters": {
"recipient_search_text": [normalized_uei],
"award_type_codes": ["02", "03", "04", "05", "F001", "F002"],
"time_period": [{
"start_date": coverage_start,
"end_date": coverage_end,
}],
},
"fields": [
"Award ID", "Recipient Name", "Recipient UEI",
"Award Amount", "generated_internal_id",
],
"page": page,
"limit": 100,
"sort": "Award ID",
"order": "asc",
"subawards": False,
},
timeout=30,
)
response.raise_for_status()
payload = response.json()
messages = payload.get("messages")
if not isinstance(messages, list) or not all(isinstance(note, str) for note in messages):
raise RuntimeError("USAspending response omitted its messages array")
normalized_notes = [note.strip().replace(" ", " ") for note in messages]
if normalized_notes != [EXPECTED_COVERAGE_NOTE]:
raise RuntimeError("USAspending returned an unknown coverage message")
if coverage_notes is not None and normalized_notes != coverage_notes:
raise RuntimeError("USAspending coverage messages changed during pagination")
coverage_notes = normalized_notes
rows = payload.get("results")
metadata = payload.get("page_metadata")
if not isinstance(rows, list) or not isinstance(metadata, dict):
raise RuntimeError("USAspending response omitted results or page metadata")
if metadata.get("page") != page or not isinstance(metadata.get("hasNext"), bool):
raise RuntimeError("USAspending pagination metadata is inconsistent")
for row in rows:
if row.get("Recipient UEI") != normalized_uei:
continue
award_key = row.get("generated_internal_id")
if not award_key or award_key in seen_awards:
raise RuntimeError("USAspending returned a missing or duplicate award key")
award_amount = row.get("Award Amount")
if isinstance(award_amount, bool) or not isinstance(award_amount, (int, float)):
raise RuntimeError("USAspending omitted the award total-obligation value")
row["Award Amount (total obligation; not outlay/payment)"] = row.pop("Award Amount")
seen_awards.add(award_key)
exact_rows.append(row)
if not metadata["hasNext"]:
return {
"retrieved_at_utc": acquired_at.isoformat(),
"coverage_start": coverage_start,
"coverage_end": coverage_end,
"coverage_notes": coverage_notes,
"measure": "total obligation, not an outlay or payment",
"rows": exact_rows,
}
page += 1
raise RuntimeError("USAspending result exceeded the 100,000-award review bound")
# Never send an EIN through recipient_search_text: that field searches recipient
# name, UEI, and legacy DUNS. Link IRS and USAspending only when an official source
# explicitly maps the exact EIN to the exact UEI; otherwise keep the records apart.
# Grants made (990 Schedule I) and federal obligations are different measures:
# the 990 shows what a foundation grants; this query shows prime grant and
# cooperative-agreement obligations associated with the exact UEI.This helper is deliberately grants-only: codes 02–05 cover legacy grant and cooperative-agreement award types, while F001 and F002 cover the current grant and cooperative-agreement codes. Contracts use A–D, other assistance uses separate codes, and subawards require subawards: true with a different response schema. The request explicitly covers October 1, 2007 through its acquisition date, retains a UTC acquisition timestamp, validates and preserves the API's known coverage note, and fails closed on any unknown message. Older history requires the USAspending download or bulk-download interfaces. A complete federal-award view must query and label the other award types separately.
Award obligations and grants made — don't conflate them
The most common mistake in nonprofit data is mixing directions. A private foundation's 990 Schedule I shows the grants it makes — money out. In this endpoint, USAspending's Award Amount is the prime award's total obligation: a federal commitment, not an outlay, payment, disbursement, or proof of cash received. The same organization can appear as both a grantor on its own 990 and a federal award recipient. A clean pipeline keeps those measures distinct and queries subawards separately when studying commitments recorded for an indirect recipient.
The gotchas
- 990 filing lag and form type. Returns post a year or more late, and 990-EZ and 990-PF carry different fields than the full 990 — a panel has to handle all three.
- Church filing exceptions. Churches, certain integrated auxiliaries, and conventions or associations of churches generally are not required to file Form 990. Missing returns are not a compliance finding.
- BMF coverage. The account/status extract is not a uniform recognition list and omits self-declared organizations and churches or other organizations that were not required to apply and did not apply. NTEE X is religion-related, not a definitive church flag.
- Missing stable identifiers. A name-and-location match can nominate a record for manual review, but the public data must keep it unconfirmed unless an exact EIN, UEI, award ID, or other official key closes the join.
- Institution-only publication. This project does not ingest or republish officer, director, preparer, donor, or compensation fields; cross-source joins and published aggregates remain at the institution level.
- Fiscal-year misalignment. A nonprofit's 990 fiscal year rarely matches the federal fiscal year of its awards; align on periods, not calendar years.
- Award-type boundaries. Grant, cooperative-agreement, contract, other-assistance, and subaward queries use different codes and sometimes different fields. Do not describe a grants-only response as all federal awards.
- Name drift and DBAs. The legal name on the 990, the recipient name on USAspending, and the public “doing business as” name often differ — the recurring entity-resolution tax.
Related writing: Following the Money — the broader synthesis on tracing any entity through federal spending, contracts, and ownership records, of which the nonprofit EIN join is one clean case.
See also: IRS Form 990 — the dataset behind the finances-and-governance stage of this pipeline.