DEV Community

Your Evenings Data
Your Evenings Data

Posted on

A daily SAM.gov bid digest by NAICS code, no API key (Python)

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. It's Windows-1252, not UTF-8. Decode it as UTF-8 and you'll hit errors or garbled characters in titles and descriptions.
  3. It's regenerated once a day. The Last-Modified header 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}")
Enter fullscreen mode Exit fullscreen mode

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=True plus io.TextIOWrapper over r.raw lets csv.DictReader parse rows as they arrive, so the break actually saves the download. decode_content = True makes 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 SetASide or as text like "No Set aside used". Filter on SetASideCode (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_at taken from the extract's Last-Modified time.
  • Filters applied in code on every row: keyword (whole phrases, with matchedKeywords on each row), NAICS prefix, agency, set-aside code, notice type, posted window and due date.
  • Grants too. Set opportunityType to both for Grants.gov opportunities in the same schema.
  • Only new notices. Turn on monitorMode and 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"])
Enter fullscreen mode Exit fullscreen mode

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)