DEV Community

Data Factory
Data Factory

Posted on

Track new SAM.gov contract opportunities with Python (no SAM.gov API key)

If you sell to the US federal government, SAM.gov is where the work is posted: solicitations, sources-sought notices, presolicitations and award notices from every agency. The website is fine for browsing, but most teams want something else: a list of open bids that match what they do, in a spreadsheet or CRM, refreshed every morning, with only the new ones flagged.

This tutorial shows how to get there in about 30 lines of Python, using a hosted scraper on Apify (SAM.gov Contract Opportunities Scraper). You don't need a SAM.gov account or API key. You need an Apify account and its API token.

What we're building

  1. A one-off export: open IT-services opportunities (NAICS 541511/541512/541519) into sam_bids.csv, sorted by response deadline, with a direct link to the first attachment (usually the SOW or RFP).
  2. Narrower searches by PSC code and agency (e.g. janitorial work for the Forest Service).
  3. A daily alert that returns only notices you haven't seen yet.

1. Get your API token

Sign in to Apify, open Settings → API & Integrations, and copy your API token. Put it in an environment variable rather than in code:

export APIFY_TOKEN=...   # your token
pip install requests
Enter fullscreen mode Exit fullscreen mode

2. Export open bids to CSV

The Apify API has an endpoint that runs an Actor, waits for it, and returns the results in one request: run-sync-get-dataset-items. (It's meant for short runs. Apify's docs say runs longer than 300 seconds return HTTP 408, so keep maxItems modest or use the async API for big pulls.)

import csv
import os

import requests

ACTOR = "data_factory_nerd~sam-gov-contract-opportunities"
URL = f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items"

run_input = {
    "naicsCodes": ["541511", "541512", "541519"],  # IT services
    "noticeTypes": ["o", "k", "p", "r"],           # solicitations, combined, presolicitations, sources sought
    "openForResponseOnly": True,                   # skip notices whose deadline has passed
    "includeAttachments": True,                    # direct links to the SOW / RFP documents
    "maxItems": 50,
}

resp = requests.post(
    URL,
    headers={"Authorization": f"Bearer {os.environ['APIFY_TOKEN']}"},
    json=run_input,
    timeout=300,
)
resp.raise_for_status()
notices = resp.json()

with open("sam_bids.csv", "w", newline="") as f:
    w = csv.writer(f)
    w.writerow(["deadline", "title", "agency", "naics", "set_aside", "url", "first_attachment"])
    for n in sorted(notices, key=lambda n: n.get("responseDeadline") or "9999"):
        att = (n.get("attachments") or [{}])[0].get("url")
        w.writerow([n.get("responseDeadline"), n["title"], n.get("subTier") or n.get("department"),
                    n.get("naicsCode"), n.get("setAside"), n["url"], att])

print(f"Saved {len(notices)} open opportunities to sam_bids.csv")
Enter fullscreen mode Exit fullscreen mode

When I ran it (Nov 12, 2026, ~5 PM ET) it took about 20 seconds:

Saved 48 open opportunities to sam_bids.csv
Enter fullscreen mode Exit fullscreen mode

The first rows looked like this (trimmed):

deadline,title,agency,naics,...
2026-11-13T17:00:00+00:00,W50NH9-27-R-A006 ... Scalable GEOINT ...,DEPT OF THE ARMY,541519,...
2026-11-13T20:00:00+00:00,Enterprise Next (ENX) Draft RFP,NATIONAL AERONAUTICS AND SPACE ADMINISTRATION,541512,...
2026-11-13T20:00:00+00:00,Request for Information: Air Operations Center (AOC) ...,DEPT OF THE AIR FORCE,541512,...
Enter fullscreen mode Exit fullscreen mode

A few things worth knowing about the data:

  • Dates are UTC ISO 8601, so sorting by deadline is a plain string sort.
  • department / subTier / office give the full agency path. When a search hit doesn't include it, the scraper looks it up in SAM.gov's federal hierarchy.
  • contacts lists the official points of contact (name, email, phone) from the notice.
  • attachments holds name, type, url, sizeBytes and postedDate. For files, the URL is a sam.gov download link that redirects to the document. Some notices point to external systems instead (for example DoD's PIEE). Those come through as type: "link" with the external URL.

Need Excel instead? The same endpoint accepts format=xlsx or format=csv as a query parameter, so you can skip the CSV code entirely.

3. Narrow it down: PSC codes and agencies

NAICS describes the industry. PSC (Product Service Code) describes what is being bought, and it's often the sharper filter. Combine it with an agency:

run_input = {
    "pscCodes": ["S201"],              # custodial / janitorial services
    "agencies": ["FOREST SERVICE"],    # name, acronym (GSA, DOD) or numeric org ID
    "openForResponseOnly": True,
    "includeAttachments": True,
    "maxItems": 100,
}
Enter fullscreen mode Exit fullscreen mode

The run log shows how each agency was matched, so you can confirm you got the right organization:

Agency 'FOREST SERVICE' -> FOREST SERVICE (org ID 100013241)
query='*': 10 matching notices on SAM.gov
Done: 6 SAM.gov notices saved
Enter fullscreen mode Exit fullscreen mode

Names that can't be matched are skipped with a warning, not silently ignored. Acronyms like DOD or GSA and short names like navy resolve to the official hierarchy entries (DEPT OF DEFENSE, GENERAL SERVICES ADMINISTRATION, DEPT OF THE NAVY).

4. Daily alerts: only new notices

Re-running the same search every day gives you the same notices again. Turn on monitor mode instead:

run_input = {
    "naicsCodes": ["541511", "541512", "541519"],
    "noticeTypes": ["o", "k", "p", "r"],
    "openForResponseOnly": True,
    "onlyNew": True,          # return only notices not returned by a previous run of this search
    "maxItems": 500,
}
Enter fullscreen mode Exit fullscreen mode

How it works:

  • The first run returns everything that matches and remembers the notice IDs.
  • Later runs return only notices that weren't returned before. If nothing new was posted, you get an empty dataset, and with pay-per-result pricing an empty run produces no result charges.
  • The memory lives in a named key-value store in your Apify account (data-factory-samgov-monitor), one record per search. Change the filters and you get a fresh history. To keep one history while you tweak filters, set "monitorKey": "it-bids".

To verify it, I ran the PSC + agency search above twice in a row. The first run returned 6 notices. The second returned 0 and logged:

Monitor mode: 6 items already seen for this search (...). Only new items will be returned.
Done: 0 new SAM.gov notices saved
Enter fullscreen mode Exit fullscreen mode

Put it on a schedule

  1. In Apify Console, open the Actor, enter the input above, and save it as a task.
  2. Go to Schedules → Create new, add the task, and pick a cron expression such as 0 8 * * * (every day at 8 AM; schedules support time zones).
  3. Add an integration or webhook to the task (Slack, email, Google Sheets, Zapier, Make) so each morning's new notices land where your capture team works.

If you'd rather keep it in code, run the snippet from step 2 with "onlyNew": True from your own cron job. The memory lives on the Apify side, so it works from any machine.

Cost

The scraper charges per result: $3.00 per 1,000 notices at the time of writing (see the Store page for the current price). A daily monitor that finds 20 new notices costs about 6 cents per day. You can cap spend on any run with the maxTotalChargeUsd query parameter.

Notice type codes cheat sheet

Code Meaning
o Solicitation
k Combined Synopsis/Solicitation
p Presolicitation
r Sources Sought
s Special Notice
a Award Notice
u Justification (J&A)
i Intent to Bundle
g Sale of Surplus Property

Wrap-up

You now have:

  • a CSV of open bids with deadlines, agencies and document links,
  • PSC and agency filters for targeted searches, and
  • a daily "only new" feed that costs nothing on days with nothing new.

The scraper is on the Apify Store: SAM.gov Contract Opportunities Scraper. Missing a field or filter? Open an issue on its Issues tab.


The full script is on GitHub: data-factory-nerd/examples.

Top comments (0)