You don't need a SAM.gov API key to get a daily list of new contract opportunities for your NAICS codes. SAM.gov publishes every current contract opportunity as a public CSV that it regenerates once a day. With a bit of care, you can read just the part you need in under a second and turn it into a digest like this:
- [MCC Software Development Recompete](https://sam.gov/opp/eb0fceb8429d48a59dbe967937b9717e/view)
Sources Sought | MILLENNIUM CHALLENGE CORPORATION | NAICS 541512 | No Set aside used | due 2026-10-29
The file
The Contract Opportunities extract lives in SAM.gov's Data Services, and this URL redirects to the current file:
https://sam.gov/api/prod/fileextractservices/v1/api/download/Contract%20Opportunities/datagov/ContractOpportunitiesFullCSV.csv?privacy=Public
Three facts about it make the script below possible:
-
It's sorted newest first by
PostedDate. You can stream it and stop as soon as you pass your window, instead of downloading the whole (large) file. - It's Windows-1252, not UTF-8. Decode it as UTF-8 and you'll hit errors or garbled characters in titles and descriptions.
-
It's regenerated once a day. The
Last-Modifiedheader tells you which day's data you're looking at. A notice posted today usually appears in tomorrow's file.
Useful columns: NoticeId, Title, Sol#, Department/Ind.Agency, PostedDate, Type, SetASideCode, SetASide, ResponseDeadLine, NaicsCode, Active and Description.
The script
import csv, io, requests
from datetime import date, timedelta
EXTRACT = ("https://sam.gov/api/prod/fileextractservices/v1/api/download/"
"Contract%20Opportunities/datagov/ContractOpportunitiesFullCSV.csv?privacy=Public")
NAICS = ("5415",) # prefixes: 5415 covers 541511, 541512, 541519...
SET_ASIDES = None # e.g. {"SBA", "8A", "SDVOSBC"}; None = any
DAYS = 2
since = (date.today() - timedelta(days=DAYS)).isoformat()
hits, scanned = [], 0
with requests.get(EXTRACT, stream=True, timeout=60) as r:
r.raise_for_status()
print("extract last modified:", r.headers.get("Last-Modified"))
r.raw.decode_content = True
for row in csv.DictReader(io.TextIOWrapper(r.raw, encoding="cp1252", newline="")):
scanned += 1
posted = row["PostedDate"][:10]
if posted < since:
break # newest first: everything after this is older
if row["Active"] != "Yes" or row["Type"] == "Award Notice":
continue
if NAICS and not row["NaicsCode"].startswith(NAICS):
continue
if SET_ASIDES and row["SetASideCode"] not in SET_ASIDES:
continue
hits.append(row)
print(f"{len(hits)} matches in {scanned:,} rows posted since {since}\n")
for row in hits:
due = row["ResponseDeadLine"][:10] or "no deadline"
print(f"- [{row['Title'].strip()}](https://sam.gov/opp/{row['NoticeId']}/view)")
print(f" {row['Type']} | {row['Department/Ind.Agency']} | NAICS {row['NaicsCode']} "
f"| {row['SetASide'] or 'no set-aside'} | due {due}")
When I ran it on 22 Oct 2026 (NAICS prefix 5415, last 2 days), it read 2,683 rows, found 40 matches and stopped, all in about a second. Your numbers will differ every day.
Gotchas this handles (and a few it doesn't)
-
Stream, don't download.
stream=Trueplusio.TextIOWrapperoverr.rawletscsv.DictReaderparse rows as they arrive, so thebreakactually saves the download.decode_content = Truemakes sure compressed responses are decoded. -
NAICS prefixes.
startswith(("5415",))takes a tuple, so you can list several prefixes. Some notices have no NAICS code at all (special notices especially), so they never match a NAICS filter. Drop the filter if you want them. - Award notices and inactive rows. The extract includes award notices and inactive notices. Skip them for a bid digest.
-
Set-aside text isn't consistent. "No set-aside" can show up as an empty
SetASideor as text like "No Set aside used". Filter onSetASideCode(SBA,8A,SDVOSBC,WOSB,HZC…), not the description. -
Deadlines carry their own time zone, for example
2026-11-20T15:00:00-05:00. Slicing the first 10 characters gives the local date. Parse the full string if you're comparing across zones. - Not handled: attachments and changes. The CSV has no attachments, and this script only looks at newly posted notices. For anything you're actually bidding on, follow the notice on SAM.gov and re-read the attachments before you submit.
Run it every morning
The extract is regenerated once a day, so run the script once a day, a few hours after the file updates (the Last-Modified header shows when that was). A cron line like 0 12 * * 1-5 python3 sam_digest.py > digest.md and any mail or Slack step you already have will do. To avoid repeats across days, save the NoticeIds you've sent and skip them next time.
The one-call version (disclosure: we built this)
We packaged this as a pay-per-use Apify Actor: SAM.gov & Grants.gov Scraper. It isn't an official government product and isn't affiliated with GSA or SAM.gov.
-
No key. It reads the same public daily extract, stops at your date window, and gives every row a
source_updated_attaken from the extract's Last-Modified time. -
Filters applied in code on every row: keyword (whole phrases, with
matchedKeywordson each row), NAICS prefix, agency, set-aside code, notice type, posted window and due date. -
Grants too. Set
opportunityTypetobothfor Grants.gov opportunities in the same schema. -
Only new notices. Turn on
monitorModeand schedule it daily to get only notices you haven't seen before. -
Amendment alerts. List the solicitation numbers you're bidding on in
trackSolicitationNumbers, and a daily run reports new notices, amendments, deadline moves and notices leaving the active list. Alerts are next-day, not real-time (more here).
from apify_client import ApifyClient
client = ApifyClient("<APIFY_TOKEN>")
run = client.actor("yourevenings/us-gov-opportunities").call(run_input={
"opportunityType": "contracts",
"naicsCodes": ["5415"],
"postedWithinDays": 2,
"maxResults": 0,
})
for opp in client.dataset(run["defaultDatasetId"]).iterate_items():
print(opp["postedDate"], opp["noticeType"], opp["agency"], opp["title"], opp["responseDeadline"])
If you'd rather use the keyed SAM.gov API, we wrote up its rules (key, MM/dd/yyyy dates, the default limit of 1, paging) here: SAM.gov API for contract opportunities.
Prices are on the Actor's Pricing tab.
Which NAICS codes do you track? Tell us in the comments.
Top comments (0)