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.