DEV Community

Your Evenings Data
Your Evenings Data

Posted on

Get an alert when a SAM.gov solicitation you're bidding on is amended (Python, no API key)

You've found a solicitation and you're writing the bid. Then the agency posts an amendment that moves the deadline or changes the scope, and you find out late. Here's a small script that checks a watch list of solicitation numbers every morning and tells you what changed. It needs no API key.

Where the data comes from

SAM.gov publishes every active contract opportunity as a public CSV extract, regenerated once a day (around 03:30 UTC recently):

https://sam.gov/api/prod/fileextractservices/v1/api/download/Contract%20Opportunities/datagov/ContractOpportunitiesFullCSV.csv?privacy=Public

The trick is that an amendment shows up as a new notice ID under the same solicitation number (the Sol# column). So to track a solicitation, save its notice IDs (and the fields you care about), then compare them tomorrow:

  • A new ID means an amendment or follow-on notice.
  • Same ID, different fields means it was edited in place.
  • An ID that's gone means the notice left the active list.

Unlike a "new bids" digest, you can't stop reading early: amendments land under solicitations that were first posted weeks ago. So read the whole file. It was about 210 MB on 22 Oct.

The script

import csv, json, re, requests
from pathlib import Path

EXTRACT = ("https://sam.gov/api/prod/fileextractservices/v1/api/download/"
           "Contract%20Opportunities/datagov/ContractOpportunitiesFullCSV.csv?privacy=Public")
WATCH = ["1240LT26Q0109", "W912DY26R0001"]      # the solicitations you're bidding on
STATE = Path("sam_watch.json")
FIELDS = ["Type", "ResponseDeadLine", "SetASideCode", "NaicsCode", "Title", "ArchiveDate"]

norm = lambda s: re.sub(r"[^A-Z0-9]", "", s.upper())
watch = {norm(s) for s in WATCH}

# Amendments land under old solicitation numbers, so read the whole file
# (about 210 MB). Save it to disk first: streaming csv straight off r.raw
# to the end of the file fails with "I/O operation on closed file".
with requests.get(EXTRACT, stream=True, timeout=120) as r:
    r.raise_for_status()
    print("extract last modified:", r.headers.get("Last-Modified"))
    with open("extract.csv", "wb") as f:
        for chunk in r.iter_content(1 << 20):
            f.write(chunk)

current = {}                                    # sol# -> {notice_id: fields}
with open("extract.csv", encoding="cp1252", newline="") as f:
    for row in csv.DictReader(f):
        sol = norm(row["Sol#"])
        if sol in watch and row["Active"] == "Yes":
            current.setdefault(sol, {})[row["NoticeId"]] = {
                "posted": row["PostedDate"], **{k: row[k] for k in FIELDS}}

saved = json.loads(STATE.read_text()) if STATE.exists() else {}
for sol in sorted(watch):
    now, before = current.get(sol, {}), saved.get(sol)
    if before is None:
        print(f"{sol}: tracking started, {len(now)} active notice(s)"
              + ("" if now else " (not in the active extract)"))
        continue
    for nid in now.keys() - before.keys():
        print(f"{sol}: NEW notice {nid} ({now[nid]['Type']}, posted {now[nid]['posted'][:10]}, "
              f"due {now[nid]['ResponseDeadLine'][:10] or 'n/a'}) https://sam.gov/opp/{nid}/view")
    for nid in now.keys() & before.keys():
        diffs = [f for f in FIELDS if now[nid][f] != before[nid][f]]
        if diffs:
            print(f"{sol}: notice {nid} edited: " +
                  "; ".join(f"{f} {before[nid][f]!r} -> {now[nid][f]!r}" for f in diffs))
    for nid in before.keys() - now.keys():
        print(f"{sol}: notice {nid} left the active list (archived, cancelled or removed)")
    if now.keys() == before.keys() and not any(now[n] != before[n] for n in now):
        print(f"{sol}: no change")

STATE.write_text(json.dumps({s: current.get(s, {}) for s in watch}, indent=1))
Enter fullscreen mode Exit fullscreen mode

The first run prints tracking started for each number and saves the state. Later runs print lines like:

1240LT26Q0109: NEW notice cbe71bb9577648beb6b5961f0588c070 (Combined Synopsis/Solicitation, posted 2026-10-21, due 2026-11-17) https://sam.gov/opp/cbe71bb9577648beb6b5961f0588c070/view
1240LT26Q0109: notice c10e3f85ab83436ebc656c3048780c7f edited: ResponseDeadLine '2026-11-04T15:00:00-07:00' -> '2026-11-17T15:00:00-07:00'
W912DY26R0001: no change
Enter fullscreen mode Exit fullscreen mode

Run it daily with cron, after about 04:00 UTC so the day's extract is ready: 15 5 * * * cd ~/sam-watch && python3 track.py.

Gotchas

  • Save the file, then parse it. Streaming csv straight off r.raw works if you break early. If you read to the end, it fails with ValueError: I/O operation on closed file. Writing to disk first avoids that, and it's only a few seconds.
  • Windows-1252, not UTF-8. Open the file with encoding="cp1252", or titles with curly quotes will break.
  • Normalise solicitation numbers. People write W912DY-26-R-0001, and the extract may say W912DY26R0001. The script strips everything but letters and digits on both sides.
  • Deadlines carry their own time zone, such as -07:00. Compare full strings, or parse them, rather than slicing the date.
  • It's next-day, not real-time. A change posted today is usually in tomorrow's extract. Check Last-Modified: if it hasn't moved since your last run, there's nothing new to see.
  • Attachments aren't in the extract. An amendment that only swaps a PDF is invisible unless SAM.gov also posts a notice or edits the fields. For anything you're actively bidding on, keep the SAM.gov "follow" button on as well.
  • "Left the active list" isn't "cancelled". The extract doesn't say why a notice went. Open it on SAM.gov to check.

The hosted version (disclosure: we built it)

If you'd rather not run a cron job, our Apify Actor, SAM.gov & Grants.gov Scraper, does the same thing with trackSolicitationNumbers (up to 500 numbers):

  • It returns a change row per new_notice, notice_updated or notice_removed, with a plain-English changeSummary and before and after values for each changed field.
  • Every tracked number appears in every run: unchanged numbers get a free unchanged row.
  • It costs $0.003 per change row plus $0.01 for a run that finds changes. Runs with no changes pay only the $0.00005 start fee.
{ "trackSolicitationNumbers": ["1240LT26Q0109", "W912DY26R0001"],
  "stateStoreName": "my-bids-watchlist" }
Enter fullscreen mode Exit fullscreen mode

Not affiliated with GSA or SAM.gov. Always confirm deadlines on the notice itself.

Top comments (0)