If you sell anything to the US federal government, you live and die by SAM.gov's Contract Opportunities — every proposed contract action over $25,000 gets posted there. The problem is consuming it:
- The website is slow, aggressively bot-protected, and unusable for bulk work.
- The official API requires a SAM.gov account, an API key that expires every 90 days, and has rate limits that hurt (Google "sam.gov api key" and enjoy the pain threads).
- The GovCon SaaS tools that repackage this data start around $100/month and run into the thousands.
Here's the thing almost nobody seems to know: the sam.gov website's own search backend is a public, keyless JSON API. No account. No API key. No 90-day key rotation. It's the same endpoint your browser hits when you search Contract Opportunities — it's just never advertised as an API.
(You may have read older posts pointing at SAM.gov's daily CSV extract on S3 — ContractOpportunitiesFullCSV.csv. That was the best free path for years, but since late July 2026 SAM.gov has been regenerating it as a header-only stub: 47 column names, zero rows. If your pipeline was built on it, that's why it's suddenly empty. The search API below is the working replacement — and unlike the 200+ MB daily download, it filters server-side.)
The endpoint
https://sam.gov/api/prod/sgs/v1/search/
Standard query parameters, JSON out. The important ones:
| Param | Meaning |
|---|---|
index=opp&mode=search&responseType=json |
boilerplate — always send these |
page / size
|
paging; size max 100 |
sort=-modifiedDate |
newest first |
is_active=true |
only still-open notices |
naics |
comma-separated NAICS codes (prefixes work) |
pop_state |
place-of-performance states, e.g. TX,FL
|
set_aside |
set-aside codes (SBA, 8A, WOSB, SDVOSBC...) |
publish_date.from / .to
|
needs an explicit UTC offset, e.g. 2026-07-01-04:00
|
q + qMode=ALL
|
keyword search |
Check it yourself — active custom-programming solicitations, newest first:
curl "https://sam.gov/api/prod/sgs/v1/search/?index=opp&mode=search&responseType=json&sort=-modifiedDate&size=5&page=0&is_active=true&naics=541511" \
-H "Accept: application/hal+json"
Two things the search results don't carry: the contracting officer's contacts and the full description. Those live on a second keyless endpoint, one call per notice:
https://sam.gov/api/prod/opps/v2/opportunities/<noticeId>
DIY: a NAICS watcher in ~35 lines of Python
import requests
SEARCH = "https://sam.gov/api/prod/sgs/v1/search/"
DETAIL = "https://sam.gov/api/prod/opps/v2/opportunities/{}"
HEADERS = {"Accept": "application/hal+json"}
params = {
"index": "opp", "mode": "search", "responseType": "json",
"sort": "-modifiedDate", "size": 100, "page": 0,
"is_active": "true",
"naics": "541511,541512", # custom programming, systems design
"publish_date.from": "2026-07-01-04:00",
}
while True:
data = requests.get(SEARCH, params=params, headers=HEADERS, timeout=60).json()
results = data.get("_embedded", {}).get("results", [])
if not results:
break
for r in results:
# contacts + description live on the detail endpoint
d = requests.get(DETAIL.format(r["_id"]), headers=HEADERS, timeout=60).json()
data2 = d.get("data2", {})
poc = next(iter(data2.get("pointOfContact") or []), {})
due = (data2.get("solicitation", {}).get("deadlines", {}).get("response") or "?")[:10]
print(f'{(r.get("publishDate") or "")[:10]} {r["title"][:70]} '
f'due={due} poc={poc.get("email", "")} '
f'https://sam.gov/opp/{r["_id"]}/view')
if (params["page"] + 1) * params["size"] >= data["page"]["totalElements"]:
break
params["page"] += 1
That's a working daily bid-pipeline feed with zero accounts and zero dollars. Cron it, pipe it to Slack, done.
Things you'll add within a week of using it seriously: the per-notice detail calls are 1:1 with results, so you'll want concurrency with a cap and retry/backoff (sam.gov will throttle a tight loop — it's the same bot protection the website has); the API stops paging at 10,000 records per query, so broad searches need date-windowing; the date params silently return wrong windows without that UTC offset; set-aside and notice-type filtering take internal codes; and you'll want dedupe across days. None of it is hard; all of it is maintenance.
The maintained shortcut
I package exactly this — the same search API the sam.gov site uses, paged, enriched with the per-notice contact details, all filters server-side, with the (currently broken) CSV extract kept as an automatic fallback source — as an Apify actor:
SAM.gov Contract Opportunities Scraper
Everything still biddable in construction trades, small-business set-aside, in Texas or Florida:
{
"naicsCodes": ["236", "237", "238"],
"setAsides": ["SBA"],
"popStates": ["TX", "FL"],
"postedAfter": "2026-06-01",
"responseDueAfter": "2026-07-11",
"maxResults": 1000
}
Out comes clean JSON/CSV/Excel with structured contacts, award data on award notices, and direct SAM.gov links. Schedule it daily in Apify, add a webhook, and you have the core of the $300/month GovCon alert products for about the price of a coffee per month (pricing is per record returned).
Which route should you take?
- One NAICS code, personal use → the Python script above. Seriously, it's ~35 lines.
- Multiple filters, scheduling, teammates who want CSVs, or feeding an AI agent → the actor (it's also callable as an MCP tool via Apify, which makes "any new cybersecurity RFPs today?" a one-line agent query).
- Enterprise BD with pipeline analytics → that's when the SaaS subscriptions earn their keep.
The data itself is public, official, and free either way — which is exactly how government contracting data should be.
Top comments (0)