The public spending_by_award search API — documented from real requests we sent and real responses we watched come back, including two undocumented bugs that will silently corrupt a naive integration.
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.
POST https://api.usaspending.gov/api/v2/search/spending_by_award/
No authentication, no API key. Content-Type application/json.
{
"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
}
filters object| Key | Shape | Notes |
|---|---|---|
time_period | array 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_codes | array of strings | Required. 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. |
agencies | array 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_text | array of strings | Free-text match against recipient/vendor name. Case-insensitive substring-style matching in practice. |
naics_codes | array of strings | 6-digit NAICS industry codes. Contracts only — always null on grant/assistance records regardless of whether you filter by it. |
psc_codes | array of strings | Product/Service Codes. Contracts only. |
place_of_performance_locations | array of {country, state, ...} | country is a 3-letter code ("USA"); state is the 2-letter postal code. |
fields arrayA 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.
fields is not server-validatednull.
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.
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
}'
{
"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, ... }
}
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.
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
}'
{
"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']"
}
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'.
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.
page_metadata.hasNext is unreliable past ~page 100hasNext 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.
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.
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."
}
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 100The 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.
This is the single most common source of confusion in USAspending data, and it trips up experienced analysts, not just newcomers.
| Field | What 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 Outlays | Dollars 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 value | Not 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.
| Code | Category | Meaning |
|---|---|---|
A | Contract | BPA Call (order placed against a Blanket Purchase Agreement) |
B | Contract | Purchase Order |
C | Contract | Delivery Order |
D | Contract | Definitive Contract |
02 | Grant / Assistance | Block grant |
03 | Grant / Assistance | Formula grant |
04 | Grant / Assistance | Project grant |
05 | Grant / Assistance | Cooperative 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.
422/400s documented above) as signals to fix the request, not to retry.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.
Every endpoint on this page is public and unauthenticated — no login, no API key, no session cookie. This is the same API usaspending.gov's own search UI calls from a visitor's browser.
usaspending.gov/award/{generated_internal_id}) where practical.