If you sell to governments on both sides of the Atlantic, you check two portals every morning: SAM.gov for US federal contracts and TED for EU public tenders. Both publish through APIs. Here is what each needs, a standard-library Python script for each, and a short script that runs both and merges them into one CSV you can put on a schedule.
Disclosure: I maintain the two Apify Actors mentioned in the second half. The scripts in the first half are free and stand on their own.
What each API wants
SAM.gov Opportunities API: needs a free API key (renew about every 90 days) and has a small daily quota, so check the table on open.gsa.gov. postedFrom and postedTo are required, formatted MM/dd/yyyy, and span at most one year. offset is a page index from 0, limit is at most 1000, and you get one NAICS code and one notice type per request.
TED Search API v3: no key. A POST with an "expert query" such as buyer-country=DEU AND classification-cpv=72* (ISO alpha-3 countries, CPV prefixes allowed). Titles and buyer names come as {language: [text]}, and page size is capped at 250.
Script 1: SAM.gov (free, needs your key)
Open solicitations under NAICS 541512 that are total small business set-asides, posted in the last 30 days. I did not run this one while writing because it needs a personal key; the request format follows the API documentation.
import os
import csv
import json
import urllib.parse
import urllib.request
from datetime import date, timedelta
API_KEY = os.environ["SAM_API_KEY"] # your own free key; never share or commit it
BASE = "https://api.sam.gov/prod/opportunities/v2/search"
def fmt(d):
return d.strftime("%m/%d/%Y") # the API wants MM/dd/yyyy
def search(**params):
params["api_key"] = API_KEY
url = BASE + "?" + urllib.parse.urlencode(params)
with urllib.request.urlopen(url, timeout=30) as r:
return json.load(r)
def fetch_all(ncode, set_aside, ptype="o", days=30, page_size=1000, max_pages=3):
today = date.today()
base = {
"postedFrom": fmt(today - timedelta(days=days)), # window must be 1 year or less
"postedTo": fmt(today),
"ncode": ncode,
"typeOfSetAside": set_aside,
"ptype": ptype,
"limit": page_size,
}
rows = []
for page in range(max_pages): # each page is one request against your daily quota
res = search(**base, offset=page) # offset is the page index, starting at 0
items = res.get("opportunitiesData") or []
rows.extend(items)
if len(items) < page_size or len(rows) >= res.get("totalRecords", 0):
break
return rows
if __name__ == "__main__":
notices = fetch_all("541512", "SBA")
with open("opportunities.csv", "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(["noticeId", "title", "agency", "posted", "deadline", "setAside", "naics", "link"])
for o in notices:
w.writerow([
o.get("noticeId"), o.get("title"), o.get("fullParentPathName"),
o.get("postedDate"), o.get("responseDeadLine"),
o.get("typeOfSetAsideDescription"), o.get("naicsCode"), o.get("uiLink"),
])
print(len(notices), "notices written")
Script 2: EU TED (free, no key)
IT-services notices (CPV branch 72) from German buyers since 1 October 2026, newest first. I ran this on 2026-11-08. The query matched 840 notices. The newest two:
| Notice | Date | Buyer | Type |
|---|---|---|---|
| 774927-2026 | 2026-11-06 | IKT-Ost AoeR | can-standard |
| 774840-2026 | 2026-11-06 | Muehlenkreiskliniken (AoeR) | cn-standard |
import csv
import json
import sys
import urllib.request
ENDPOINT = "https://api.ted.europa.eu/v3/notices/search"
FIELDS = ["publication-number", "notice-title", "buyer-name", "buyer-country",
"classification-cpv", "notice-type", "publication-date",
"deadline-receipt-tender-date-lot"]
def pick(value, prefer="eng"):
"""Titles and buyer names come as {language: [text, ...]}; prefer English."""
if not isinstance(value, dict):
return value
for lang in (prefer, *value):
v = value.get(lang)
if v:
return v[0] if isinstance(v, list) else v
return None
def search(query, limit=10):
body = json.dumps({"query": query, "fields": FIELDS, "limit": limit,
"paginationMode": "ITERATION"}).encode()
req = urllib.request.Request(ENDPOINT, data=body, method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.load(r)
if __name__ == "__main__":
# German buyers (DEU), CPV branch 72 (IT services), published since 1 Oct 2026, newest first
sys.stdout.reconfigure(encoding="utf-8")
q = ("publication-date>=20261001 AND buyer-country=DEU AND classification-cpv=72* "
"SORT BY publication-number DESC")
res = search(q, limit=5)
print("total matching:", res.get("totalNoticeCount"))
rows = []
for n in res.get("notices", []):
rows.append({
"id": n["publication-number"],
"date": (n.get("publication-date") or "")[:10],
"title": pick(n.get("notice-title")),
"buyer": pick(n.get("buyer-name")),
"country": (n.get("buyer-country") or [None])[0],
"type": n.get("notice-type"),
"url": f"https://ted.europa.eu/en/notice/-/detail/{n['publication-number']}",
})
w = csv.DictWriter(sys.stdout, fieldnames=list(rows[0]) if rows else ["id"])
w.writeheader()
w.writerows(rows)
# next page: pass res["iterationNextToken"] back as "iterationNextToken" in the body
The scaled way: two Apify Actors and one digest
If you would rather not maintain two clients, I publish SAM.gov Opportunities and EU TED Tenders as Apify Actors over the same official sources.
- The SAM.gov Actor works without a key by reading SAM.gov's public daily extract (up to about a day old), or with your own key for the live API.
- The TED Actor needs no key, accepts 2- or 3-letter country codes and picks your title language.
Python, merging both into tenders.csv (pip install "apify-client>=3", token in APIFY_TOKEN):
import csv
import os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
def run(actor, run_input):
r = client.actor(actor).call(run_input=run_input)
return list(client.dataset(r.default_dataset_id).iterate_items())
us = run("datawharf-tools/sam-gov-opportunities",
{"keywords": ["software"], "noticeTypes": ["o", "k"], "maxResults": 100})
eu = run("datawharf-tools/eu-ted-tenders",
{"countries": ["DE", "FR"], "cpvCodes": ["72"], "titleLanguage": "en", "maxResults": 100})
rows = (
[{"market": "US", "id": r["noticeId"], "title": r["title"], "buyer": r["agency"],
"deadline": r.get("responseDeadline"), "link": r.get("samUrl")} for r in us]
+ [{"market": "EU", "id": r["noticeId"], "title": r["title"], "buyer": r["buyerName"],
"deadline": r.get("deadline"), "link": r.get("tedUrl")} for r in eu]
)
if rows:
with open("tenders.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0]))
w.writeheader()
w.writerows(rows)
print(len(rows), "tenders written")
A JavaScript version and more examples are in the examples repo. To make it an alert, save each input as an Apify Task, add a daily Schedule, and deliver new rows by webhook or the email, Slack or Google Sheets integrations.
Gotchas
- The keyless SAM.gov file is not live. It is rebuilt about once a day and holds active notices only. For archived or fresher notices use the live API with your key.
-
TED CPV matching. A notice matches if any lot has a matching CPV, so a row's
cpvCodescan include unrelated codes. - Missing fields are empty, not guessed. Award notices have no deadline, and many notices state no value.
- Licence and contacts. TED content is CC BY 4.0, so keep the attribution line. SAM.gov contact details are published for bidders; follow the laws that apply to you for outreach.
Cost
The scripts are free. Both Actors charge $0.40 per 1,000 result rows plus standard Apify usage, so the merge above (at most 200 rows) is about $0.08 in result charges; you can cap the charge per run.
Questions, or something that looks wrong? Tell me in the comments.
Disclosure: I maintain these Actors. They are not affiliated with or endorsed by GSA/SAM.gov or the European Union.
Top comments (0)