Why this exists

USAspending.gov runs a genuinely public, unauthenticated JSON API covering every prime federal contract and grant/assistance award since fiscal year 2008. It's the same API that powers usaspending.gov's own search UI. The official docs describe the request shape reasonably well — but they don't mention two behaviors that will quietly wreck a real pull: an unvalidated fields array that returns null instead of erroring on a typo, and a hasNext flag that starts lying partway through a large result set. We hit both while building a production scraper against this API and are documenting them here because nothing else does.

Everything below — request bodies, response shapes, error text — was captured from real calls made on the date at the top of this page.

The endpoint & request shape

Verified live — 200 OK

Endpoint

POST https://api.usaspending.gov/api/v2/search/spending_by_award/

No authentication, no API key. Content-Type application/json.

Request body shape

{
  "filters": {
    "time_period": [
      { "start_date": "2026-07-01", "end_date": "2026-08-01" }
    ],
    "award_type_codes": ["A", "B", "C", "D"],
    "agencies": [
      { "type": "awarding", "tier": "toptier", "name": "Department of Defense" }
    ],
    "recipient_search_text": ["Lockheed Martin"],
    "naics_codes": ["541511"],
    "psc_codes": ["D301"],
    "place_of_performance_locations": [
      { "country": "USA", "state": "VA" }
    ]
  },
  "fields": ["Award ID", "Recipient Name", "Award Amount", "Total Outlays"],
  "sort": "Award Amount",
  "order": "desc",
  "page": 1,
  "limit": 100
}

The filters object

KeyShapeNotes
time_periodarray of {start_date, end_date}Required in practice. Dates are YYYY-MM-DD. The API's own response includes a message noting search date ranges are limited to an earliest start of 2007-10-01 (older data requires the bulk download endpoints, not this search endpoint).
award_type_codesarray of stringsRequired. Selects contracts vs. grants — see the award type codes table. Mixing contract and grant codes in one call is allowed by the API but not recommended: the two categories have different valid fields and sort values (see below), so you'll get nulls for whichever category's fields don't apply.
agenciesarray of {type, tier, name}type is "awarding" or "funding"; tier is "toptier" or "subtier"; name must match USAspending's exact agency name string (e.g. "Department of Defense", not "DoD").
recipient_search_textarray of stringsFree-text match against recipient/vendor name. Case-insensitive substring-style matching in practice.
naics_codesarray of strings6-digit NAICS industry codes. Contracts only — always null on grant/assistance records regardless of whether you filter by it.
psc_codesarray of stringsProduct/Service Codes. Contracts only.
place_of_performance_locationsarray of {country, state, ...}country is a 3-letter code ("USA"); state is the 2-letter postal code.

The fields array

A list of field names (not raw JSON keys — they're human-readable strings like "Award ID" and "Recipient Name", mixed with a smaller number of snake_case ones like naics_code) that determines what comes back in each result object. See the critical finding below before you build this list from guesswork.

Critical finding: fields is not server-validated

Verified live — confirmed with a made-up field name
Unknown field names do not error. They silently return null. We sent a request with "fields": ["Award ID", "Totally Fake Field XYZ"] against real contract data. It returned 200 OK with real rows — and "Totally Fake Field XYZ": null on every single one, indistinguishable from a real, valid field that happens to not apply to that award type. If you typo a field name, or guess one that doesn't exist, you get a fully-formed 200 response full of nulls and no error anywhere telling you why.

The exact call that proved it

curl -X POST "https://api.usaspending.gov/api/v2/search/spending_by_award/" \
  -H "Content-Type: application/json" \
  -d '{
    "filters": {
      "time_period": [{"start_date":"2026-07-01","end_date":"2026-08-01"}],
      "award_type_codes": ["A","B","C","D"]
    },
    "fields": ["Award ID", "Totally Fake Field XYZ"],
    "sort": "Award ID",
    "order": "desc",
    "page": 1,
    "limit": 2
  }'

Real response (trimmed)

{
  "results": [
    {
      "internal_id": 360893879,
      "Award ID": "W912P526FA088",
      "Totally Fake Field XYZ": null,
      "generated_internal_id": "CONT_AWD_W912P526FA088_9700_W912EK22D0006_9700"
    }
  ],
  "page_metadata": { "page": 1, "hasNext": true, ... }
}

How to discover the genuinely valid field set

The fields array isn't validated, but sort is — and its error message is, unintentionally, the authoritative list of every field name the current award-type category actually supports. Send a request with a sort value you know is wrong, and the API's 400/422 response enumerates the real set.

Call that surfaces the contracts field list

curl -X POST "https://api.usaspending.gov/api/v2/search/spending_by_award/" \
  -H "Content-Type: application/json" \
  -d '{
    "filters": {
      "time_period": [{"start_date":"2026-07-01","end_date":"2026-08-01"}],
      "award_type_codes": ["A","B","C","D"]
    },
    "fields": ["Award ID"],
    "sort": "Totally Fake Sort Field",
    "order": "desc",
    "page": 1,
    "limit": 2
  }'

Real response — the full valid field list for contracts (award_type_codes A/B/C/D)

{
  "detail": "Sort value 'Totally Fake Sort Field' not found in Contract Award mappings: ['Award ID', 'Recipient Name', 'Recipient DUNS Number', 'recipient_id', 'Awarding Agency', 'Awarding Agency Code', 'Awarding Sub Agency', 'Awarding Sub Agency Code', 'Funding Agency', 'Funding Agency Code', 'Funding Sub Agency', 'Funding Sub Agency Code', 'Place of Performance City Code', 'Place of Performance State Code', 'Place of Performance Country Code', 'Place of Performance Zip5', 'Description', 'Last Modified Date', 'Base Obligation Date', 'prime_award_recipient_id', 'generated_internal_id', 'def_codes', 'COVID-19 Obligations', 'COVID-19 Outlays', 'Infrastructure Obligations', 'Infrastructure Outlays', 'Recipient UEI', 'naics_code', 'naics_description', 'psc_code', 'psc_description', 'cfda_number', 'cfda_program_title', 'sub_cfda_program_titles', 'recipient_location_city_name', 'recipient_location_state_code', 'recipient_location_country_name', 'recipient_location_address_line1', 'recipient_location_address_line2', 'recipient_location_address_line3', 'sub_recipient_location_city_name', 'sub_recipient_location_state_code', 'sub_recipient_location_country_name', 'sub_recipient_location_address_line1', 'pop_city_name', 'pop_state_code', 'pop_country_name', 'sub_pop_city_name', 'sub_pop_state_code', 'sub_pop_country_name', 'Start Date', 'End Date', 'Award Amount', 'Total Outlays', 'Contract Award Type']"
}

The same call against grants (award_type_codes 02/03/04/05) returns a different list

Swap award_type_codes to ["02","03","04","05"] in the same malformed-sort request and the enumerated set changes — it's headed "...not found in Non-Loan Assistance Award mappings:", includes everything contracts has (minus contract-only fields like Contract Award Type), and adds grant-specific fields not present on the contracts list: 'Award Type', 'SAI Number', 'CFDA Number', 'Assistance Listings'.

Practical technique Deliberately break sort once per award-type category you're targeting (contracts vs. grants), read the field list out of the error message, and build your real fields array from that — not from guessing based on the results UI or from older blog posts. Do this once per category and cache the list; it's the same for every subsequent real query against that category.

Critical finding: page_metadata.hasNext is unreliable past ~page 100

Verified against our own production pull — 28,092-row dataset
Trusting hasNext silently truncates large result sets. Building open-fedspend-data, we watched page_metadata.hasNext report false starting around page 100 of a sorted contract-award query — while continuing to page manually past that point returned full 100-row pages all the way through roughly page 280. Had we trusted the flag as documented, the pull would have stopped at 10,000 rows instead of its true 28,092. The flag isn't a reliable "there is/isn't more data" signal once you're deep into a large, sorted result set.

The correct stopping condition

Stop paging when a page comes back short of the requested limit (or empty) — not when hasNext says false. A full page (exactly limit rows) means there may be more; a short or empty page is the only reliable end-of-results signal we found.

// Do this:
hasNext = pageResults.length === PAGE_LIMIT;

// Not this:
hasNext = response.page_metadata.hasNext; // lies past ~page 100

This is exactly the fix shipped in open-fedspend-data's fetcher — see its fetchAllAwards() for the real implementation, including the comment documenting the exact page range where the flag failed on a live pull.

Pagination: the 50,000-row cap and the cursor workaround

Verified live — page 501 at limit 100 returns an explicit error

The cap

Simple page-number pagination (page + limit) is capped at page * limit <= 50,000. Requesting further pages doesn't return empty results — it returns an explicit error telling you exactly what to do instead:

curl -X POST "https://api.usaspending.gov/api/v2/search/spending_by_award/" \
  -H "Content-Type: application/json" \
  -d '{"filters": {...}, "fields": ["Award ID"], "sort": "Award ID", "order": "desc", "page": 501, "limit": 100}'
{
  "detail": "Page #501 with limit 100 is over the maximum result limit 50000. Please provide the 'last_record_sort_value' and 'last_record_unique_id' to paginate sequentially."
}

The cursor workaround

Beyond the cap, switch to cursor pagination using the values every page's page_metadata already includes:

{
  "spending_level": "awards",
  "limit": 2,
  "results": [ ... ],
  "page_metadata": {
    "page": 1,
    "hasNext": true,
    "last_record_unique_id": 295527116,
    "last_record_sort_value": "4277416823125"
  }
}

Pass those two values back on the next request instead of incrementing page:

{
  "filters": { ... },
  "fields": [...],
  "sort": "Award Amount",
  "order": "desc",
  "limit": 100,
  "last_record_sort_value": "4277416823125",
  "last_record_unique_id": 295527116
}

This cursor approach has no 50,000-row ceiling — it's the only way to walk a result set larger than that through this endpoint. Combine it with the short-page stopping rule above (not hasNext) for a pull that neither errors out nor silently truncates.

limit caps at 100

Verified live — limit 101 returns 422

The per-page limit parameter has a hard ceiling of 100. Requesting 101 doesn't get silently clamped to 100 — it returns an HTTP 422 error. Use 100 as your page size when you want the fewest possible requests.

Money semantics: obligated vs. outlays vs. ceiling

This is the single most common source of confusion in USAspending data, and it trips up experienced analysts, not just newcomers.

FieldWhat it actually is
Award Amount (obligated)The dollar amount the government has legally committed to pay on this award transaction. This is what most people mean by "the contract's value" in casual conversation, but it is not the same as the contract's ceiling or potential total value — a large multi-year IDV/BPA can have a small obligated amount at any given point if only a fraction has been funded so far.
Total OutlaysDollars actually disbursed to the recipient to date, as USAspending reports it. This lags obligation, often significantly, especially early in a multi-year award's life.
Contract ceiling / potential valueNot reliably present as a single field on this endpoint at all for most award types — obligated amount and outlays are what this search API gives you. Ceiling values, where they exist, generally require looking at the award's own detail page or the underlying IDV structure, outside the scope of this search endpoint.
Total Outlays can be negative We've observed this directly: Lockheed Martin's largest DOE contract in our verification pull shows "Award Amount": 48063737196.35 alongside "Total Outlays": -4166130.71. A negative outlay figure happens when de-obligations (money the government formally un-commits, e.g. contract modifications reducing scope) in the reporting period exceed new disbursements. It does not mean the recipient owes money back, and it is not a data error — it's a real, if confusing, accounting artifact of how obligation/outlay reporting works across fiscal periods.

Practical rule: if you're building anything that displays "how much is this award worth," default to Award Amount (obligated) and label it as such — don't call it "value" or "total funding" without qualification, and never conflate it with outlays or an assumed ceiling.

Award type codes reference

CodeCategoryMeaning
AContractBPA Call (order placed against a Blanket Purchase Agreement)
BContractPurchase Order
CContractDelivery Order
DContractDefinitive Contract
02Grant / AssistanceBlock grant
03Grant / AssistanceFormula grant
04Grant / AssistanceProject grant
05Grant / AssistanceCooperative agreement

Pass the codes for the category you want in award_type_codes. Contracts (A/B/C/D) and grants (02/03/04/05) have different valid fields/sort sets — see the fields section above — so query them as separate requests if you need both, rather than mixing codes in one call.

Auth, rate limits, being polite

Worked example: a full paginated pull

Putting the short-page stopping rule and the field-discovery technique together, a correct pull for all contract awards in a date window looks like:

const PAGE_LIMIT = 100;
const MAX_PAGEABLE_RESULTS = 49900; // stay under the 50,000 hard cap

async function fetchAllContracts(startDate, endDate) {
  const filters = {
    award_type_codes: ['A', 'B', 'C', 'D'],
    time_period: [{ start_date: startDate, end_date: endDate }],
  };
  const results = [];
  let page = 1;
  let pageWasFull = true;

  while (pageWasFull) {
    if (page * PAGE_LIMIT > MAX_PAGEABLE_RESULTS) break; // switch to cursor pagination here if needed

    const res = await fetch('https://api.usaspending.gov/api/v2/search/spending_by_award/', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        filters,
        fields: ['Award ID', 'Recipient Name', 'Award Amount', 'Total Outlays', 'Awarding Agency'],
        sort: 'Award Amount',
        order: 'desc',
        page,
        limit: PAGE_LIMIT,
      }),
    }).then(r => r.json());

    results.push(...res.results);
    // Correct stop condition — NOT res.page_metadata.hasNext:
    pageWasFull = res.results.length === PAGE_LIMIT;
    page += 1;
  }
  return results;
}

This is a simplified version of the real fetcher behind open-fedspend-data, which adds retry-with-backoff and request throttling.