May'd It

Data tools / Guides

USAspending API, No API Key: Federal Contract Awards

Query the USAspending API with no API key for federal contract awards: the 10,000-record hasNext cliff, five silent filter failures, and dedupe traps.

No key, no account, no token

USAspending.gov publishes federal award data through a public REST API that requires no authentication. There is no signup, no key header, no quota form. You POST JSON, you get JSON.

The endpoint for contract awards is POST https://api.usaspending.gov/api/v2/search/spending_by_award/. It is POST-only; a GET returns 405 {"detail":"Method \"GET\" not allowed."}. Both /spending_by_award/ and /spending_by_award returned 200 with no redirect when tested, so the trailing slash is not currently load-bearing. Send it anyway; it is what the documentation uses.

Exactly one filter is required: filters.award_type_codes. Omit it and you get a 422 (Missing value: 'filters|award_type_codes' is a required field). Contrary to a lot of sample code, time_period is not required.

curl -s -X POST https://api.usaspending.gov/api/v2/search/spending_by_award/ \
  -H "Content-Type: application/json" \
  -d '{
    "filters": { "award_type_codes": ["A","B","C","D"] },
    "fields": ["Award ID","Recipient Name","Award Amount","Awarding Agency","NAICS"],
    "sort": "Award Amount",
    "order": "desc",
    "limit": 100,
    "page": 1
  }'

That is the whole contract. limit is bounded at min 1 and max 100: sending 101 returns 422 Field 'limit' value '101' is above max '100', and 0 returns the matching below-min error. sort must be a member of your fields array, or you get a 400 naming the mismatch.

Count before you paginate

The search response carries no total. Its top-level keys are only spending_level, limit, results, page_metadata, and messages, and page_metadata is {page, hasNext, last_record_unique_id, last_record_sort_value}. Nothing in a response tells you how much data you are missing.

So call the count endpoint first, with the identical filters object:

curl -s -X POST https://api.usaspending.gov/api/v2/search/spending_by_award_count/ \
  -H "Content-Type: application/json" \
  -d '{"filters":{"award_type_codes":["A","B","C","D"],
       "time_period":[{"start_date":"2025-10-01","end_date":"2026-07-20"}]}}'
# => {"results":{"contracts":3278345,"direct_payments":0,"grants":0,
#                 "idvs":0,"loans":0,"other":0},"spending_level":"awards", ...}

3,278,345 contracts for FY2026 to date, measured 2026-07-20. Pagination will hand you 10,000 of them. A fields key on this endpoint is accepted and ignored, so reusing your search body is harmless, but only filters matters.

The hasNext trap: a 10,000-record cliff, not an end-of-data signal

This is the biggest failure mode in this API.

page_metadata.hasNext flips to false at record 10,000 regardless of page size. Verified against the unbounded contracts filter: limit=100 with page=100, limit=50 with page=200, and limit=10 with page=1000 all returned hasNext:false, while pages 99, 199, and 999 respectively returned true.

The data does not end there. Page 101 returned a full 100 rows and the descending sort continued cleanly across the boundary: page 100 ended at Award Amount 115,774,074.02, page 101 opened at 115,773,474.42 and ended at 114,636,312, page 102 opened at 114,622,680.94. Page 150 also returned a full 100 rows, opening at 78,246,081. Every one of those responses still reported hasNext:false.

So the canonical loop:

while (data.page_metadata.hasNext) { page++; /* ... */ }   // truncates at 10,000

...returns HTTP 200 the whole way and stops at exactly 10,000 records with no error, no warning, and no total to check against. Drive your loop off the count endpoint instead, and treat an empty results array as the real terminator.

const BASE = "https://api.usaspending.gov/api/v2/search";
const filters = {
  award_type_codes: ["A","B","C","D"],
  naics_codes: ["236220"],
  time_period: [{ start_date: "2026-01-01", end_date: "2026-03-31",
                  date_type: "date_signed" }]
};
const post = (path, body) => fetch(`${BASE}/${path}/`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(body)
}).then(r => r.json());

const { results: counts } = await post("spending_by_award_count", { filters });
console.log("matching contracts:", counts.contracts);
if (counts.contracts > 10000) console.warn("partition this query further");

const seen = new Set(), out = [];
for (let page = 1; page <= Math.ceil(counts.contracts / 100); page++) {
  const d = await post("spending_by_award", {
    filters,
    fields: ["Award ID","Recipient Name","Award Amount","Start Date","NAICS"],
    sort: "Award Amount", order: "desc", limit: 100, page
  });
  if (!d.results || d.results.length === 0) break;
  for (const r of d.results) {
    if (seen.has(r.generated_internal_id)) continue;   // NOT Award ID
    seen.add(r.generated_internal_id);
    out.push({ ...r, naics: r.NAICS?.code,
      url: `https://www.usaspending.gov/award/${r.generated_internal_id}` });
  }
}

Run this as an ES module on Node 18 or later, since it uses top-level await and the built-in fetch. Executed on 2026-07-20 it reported 1,613 matching contracts and collected all 1,613 rows.

Latency degrades with page depth, and the server appears to cache. On first request, pages 1 through 5 took 0.35 to 0.39s each, page 150 took 6.8s, page 300 took 7.9s, and page 400 took 12.2s; repeat requests for those same deep pages then returned in about 0.25s. Budget your timeouts for the cold case. No rate limiting was observed when 12 concurrent POSTs were fired at once (all returned 200, slowest 7.4s), though that is an observation from a single test, not a documented guarantee. A 100-row page requesting 14 fields measured 93,855 bytes.

Getting past 10,000 records

  1. Partition until each slice is under 10,000. Split by month, then by agency, NAICS prefix, or state, calling spending_by_award_count on each slice before you paginate it. If a slice comes back over 10,000, split it again.
  2. Use POST /api/v2/bulk_download/awards/, which returns {status_url, file_name, file_url, download_request} and produces a zip on files.usaspending.gov. It uses a different filter schema: it wants prime_award_types rather than award_type_codes, and date_range:{start_date,end_date} rather than time_period. Posting a search-style filter object returns 422 Missing value: 'filters|date_range' is a required field. A date_type key appears to be optional; the request succeeded without one. Either way, you cannot reuse your search filter object.

Five silent failures that produce wrong-but-plausible data

Defensive pattern: run your count query, then run it again with the filter under test removed. If the two counts are equal, your filter is doing nothing.

Dedupe on generated_internal_id, never Award ID

"Award ID" is the PIID and it is not unique. Filtering on award_ids:["0001"] with limit=100 returned 100 rows, all with Award ID 0001, and 100 distinct generated_internal_id values (CONT_AWD_0001_9700_FA852612D0001_9700, CONT_AWD_0001_9700_W911W408D0002_9700, and so on): delivery orders numbered 0001 under different parent IDVs.

This is not an edge case you can ignore. The 1,613-record NAICS 236220 pull above contained only 1,609 distinct Award IDs. Comparing consecutive pages of the unbounded contracts query, pages 100 and 101 shared 4 Award ID values while sharing zero generated_internal_id values. Deduping on Award ID collapses genuinely distinct awards.

Every row also carries internal_id, generated_internal_id, awarding_agency_id, and agency_slug, even though those are not names you request in fields. generated_internal_id is also your permalink: https://www.usaspending.gov/award/{generated_internal_id}.

Three fields that do not mean what they say

Query by date correctly

Use time_period with an explicit date_type. The valid values are action_date, last_modified_date, date_signed, and new_awards_only; anything else returns a clean 400 that lists them. The default is not action_date, and the choice moves results substantially. Contract counts for 2026-06-01 to 2026-06-30, measured 2026-07-20:

date_typeContracts
(omitted)155,525
action_date138,171
date_signed110,138
new_awards_only110,138
last_modified_date357,777

For "new awards this month" use date_signed or new_awards_only. For "all activity including modifications" use action_date. Note the floor as well: every response carries a messages entry stating that search is limited to an earliest start date of 2007-10-01, and a start_date before that returns a 422 JSON body pointing you at the Custom Award Download feature or the bulk download endpoints for data back to 2000-10-01.

The defense reporting lag will wreck your alerting

Recent defense contract data is largely absent from the API, and it is visible in the counts. Contracts by date_signed month, queried 2026-07-20, with Veterans Affairs as a control:

MonthDefense contractsVeterans Affairs (control)
Jan 2026327,5112,643
Mar 2026351,2633,490
Apr 2026200,2163,422
May 2026132,999
Jun 2026233,021
Jul 1-20, 2026351,562

The cliff falls between April and May 2026, roughly three months back from the query date, with April itself already depressed relative to March. Veterans Affairs over the same window is flat, so this is specific to the defense series rather than a general outage or a stale dataset; this guide does not attempt to establish the cause. The operational consequence is what matters: a "new defense awards this week" alert returns HTTP 200 and near-zero rows, which is a successful query returning months-stale data. Dataset freshness overall is daily. GET /api/v2/awards/last_updated/ returned {"last_updated":"07/20/2026"}.

Award type codes (verified)

All codes in one request must come from one group. Mixing ["A","IDV_A"] returns a 422 whose body contains the entire group-to-code mapping. The contract and IDV groups:

GroupCodeMeaning
contractsABPA Call
contractsBPurchase Order
contractsCDelivery Order
contractsDDefinitive Contract
idvsIDV_AGWAC Government Wide Acquisition Contract
idvsIDV_BIDC Multi-Agency Contract, Other Indefinite Delivery Contract
idvsIDV_B_AIDC Indefinite Delivery Contract / Requirements
idvsIDV_B_BIDC Indefinite Delivery Contract / Indefinite Quantity
idvsIDV_B_CIDC Indefinite Delivery Contract / Definite Quantity
idvsIDV_CFSS Federal Supply Schedule
idvsIDV_DBOA Basic Ordering Agreement
idvsIDV_EBPA Blanket Purchase Agreement

Note that A is not definitive contract; D is. The same error body also lists the loans, grants, other_financial_assistance, and direct_payments groups.

Filter cookbook and error reality

On error shapes: every failure observed while testing returned a JSON body with a detail or message key. Missing required filters, an out-of-range limit, a sub-3-character keyword, a float amount bound, a pre-2007 start_date, and mixed award-type groups all returned 422; a bad sort and an invalid date_type returned 400. Parse defensively regardless, but do not build your client around the assumption that these endpoints hand back HTML.

When to use something else

Skip this endpoint for pre-2007-10-01 data (use the Custom Award Download feature or the bulk download endpoints, which reach back to 2000-10-01), for subaward analysis at scale, and any time your count runs to hundreds of thousands of records, where partitioned pagination will be slower than pulling one bulk file.

Everything above is reproducible with curl and about thirty lines of JavaScript, and the API is free either way. If you would rather run it as a hosted job than maintain your own, the US Federal Contract Awards scraper on Apify wraps these same public endpoints and flattens the nested NAICS and PSC objects into flat records. Whichever route you take, apply the partitioning and the generated_internal_id dedupe described here yourself: a paging loop that trusts hasNext stops at 10,000 records no matter who wrote it.