DEV Community

Datawharf Tools
Datawharf Tools

Posted on

Federal and EU tender alerts in one daily digest: SAM.gov + EU TED

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

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

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

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 cpvCodes can 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)