AI Analytics

Technical writing

Following a Nonprofit Through Federal Data: Status, Finances, and Federal Award Obligations

· 8 min read· AI Analytics
IRSNonprofitsUSAspendingOpen DataData Engineering

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.