DEV Community

Aurelix Solutions
Aurelix Solutions

Posted on

Daily government tender alerts from SAM.gov, CanadaBuys and TED in one Python script

If you sell to governments, the hard part of public procurement is not writing the bid. It is noticing the tender in time. US federal opportunities go out on SAM.gov, Government of Canada notices on CanadaBuys, and EU tenders on TED, each with its own search, its own code system (NAICS, UNSPSC, CPV) and its own fields. Checking three portals every morning for your codes is the kind of chore that gets skipped in a busy week, and a missed closing date is a missed contract.

This post pulls all three into one table in Python, filters it the way buyers classify what they buy, and sets up a daily feed of only the new notices.

Disclosure: I'm Aurelix, and I built the Government Tenders Scraper. It's a paid tool (prices below).

What each row holds

One row per tender notice, in the same 28 fields whatever the source:

  • tender_id (stable, for joins), source (sam.gov, canadabuys or ted), notice_id, solicitation_number, url.
  • title, buyer (department or contracting authority), buyer_unit, country (ISO code) and region (state, province or buyer city).
  • notice_type, category (goods, services, construction on CanadaBuys; supplies, services, works on TED) and status (open, closed, awarded).
  • naics, unspsc and cpv codes where the source publishes them, and value with currency where the buyer publishes one.
  • published_date and closes_at, the bid deadline in ISO 8601 UTC.
  • description, and the point of contact as the notice publishes it: contact_name, contact_title, contact_email, contact_phone, plus every listed contact in contacts.

This week's tenders in your codes

pip install apify-client
Enter fullscreen mode Exit fullscreen mode
import csv
import os

from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
run = client.actor("aurelix/government-tenders-scraper").call(
    run_input={
        "sources": ["sam.gov", "canadabuys", "ted"],
        "daysBack": 7,
        "openOnly": True,
        "naicsCodes": ["5415"],
        "unspscCodes": ["4321", "8111"],
        "cpvCodes": ["72", "48"],
        "maxResultsPerSource": 200,
    }
)
tenders = list(client.dataset(run["defaultDatasetId"]).iterate_items())
tenders.sort(key=lambda t: t["closes_at"] or "9999")

fields = ["closes_at", "source", "country", "buyer", "title", "notice_type", "value", "currency",
          "solicitation_number", "contact_email", "url"]
with open("tenders.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
    w.writeheader()
    w.writerows(tenders)

for t in tenders[:10]:
    print(t["closes_at"][:10] if t["closes_at"] else "no date", t["source"], t["country"], t["title"][:70])
Enter fullscreen mode Exit fullscreen mode

Codes are prefixes: 5415 matches NAICS 541511, 541512 and so on, 72 matches every CPV code in IT services. A notice passes if it matches any NAICS, UNSPSC or CPV prefix you give, and a source that publishes none of those code systems is skipped. keywords and countries narrow it further; keywords match the title and description.

Sorting by closes_at puts the most urgent bids first, which is usually the order a bid team wants to read them.

New tenders every morning

run_input = {
    "sources": ["sam.gov", "canadabuys", "ted"],
    "daysBack": 2,
    "keywords": ["cybersecurity", "penetration testing"],
    "incremental": True,
    "stateKey": "cyber-feed",
    "slackWebhookUrl": "https://hooks.slack.com/services/...",
}
Enter fullscreen mode Exit fullscreen mode

With incremental on, the first run returns everything that matches. Each later run with the same stateKey returns only notices earlier runs did not, so a daily schedule pays only for new tenders. Save the input as a task in the Apify Console, schedule it daily, and the Slack URL gets the count and the top 10 new notices; nothing is sent when a run finds nothing. webhookUrl posts the same digest as JSON to Zapier, Make or n8n.

A daysBack of 2 or more covers SAM.gov, whose data runs one to two days behind its live site. A bid consultant can run one saved task per client, each with its own keywords and stateKey, so the feeds never share memory.

Use cases

  • Contractors and suppliers: a daily list of new tenders in your NAICS, UNSPSC or CPV codes, sorted by deadline.
  • Bid consultants: one feed per client across US, Canadian and EU tenders, each posted to its own Slack channel.
  • Sales teams: group open notices by buyer and buyer_unit to see which departments are buying what you sell, with the published contact.
  • Analysts: public procurement by buyer, category, country and value, week by week.

Why this tool

  • SAM.gov, CanadaBuys and TED in one run and one schema.
  • Filters by the buyer's own codes: NAICS, UNSPSC and CPV prefixes, plus keywords, countries and open-only.
  • The published point of contact in its own fields: name, title, email and phone.
  • Only new tenders since the last run, with a Slack or webhook digest.
  • $2.00 per 1,000 tenders ($1.40 on paid plans), cheaper than the leading tools.
  • What it doesn't cover: state, provincial and municipal portals, attachments and amendment history.

Limits worth knowing

  • Federal level only for the US and Canada. SAM.gov and CanadaBuys; state, provincial and municipal tenders are not included.
  • SAM.gov runs one to two days behind its live site, and descriptions longer than 32,000 characters are cut there; the notice link has the full text.
  • CanadaBuys lists open notices only, so turning openOnly off adds no closed Canadian notices.
  • TED titles are TED's English rendering ("Country – CPV label – original title"); the original title stays in the buyer's language.
  • Look-back is at most 7 days. For a longer history, run it daily with incremental on.
  • 500 per source per run by default, newest first; the run status says when the cap was hit. Set maxResultsPerSource higher, or 0 for no limit.

FAQ

Do I need a SAM.gov or TED account or API key? No. The tool needs no login and no key.

Why is value empty on many notices? It is filled only when the buyer publishes one: SAM.gov mostly on award notices, CanadaBuys tender notices carry none, and many TED notices do.

Does it return the contracting officer's email? Yes, as each notice publishes it. Nothing is guessed: a field the notice leaves blank stays empty.

What does a run with no matches cost? Only the start fee. You pay per tender written to the dataset.

Can an AI agent use it? Yes, through Apify's MCP server or API: give the sources, days back and any filters.

Cost

$2.00 per 1,000 tenders on the free plan and $1.40 on paid plans, plus a $0.006 start fee per run. Sources with no matches cost nothing beyond the start fee. A daily feed of about 30 new tenders comes to roughly $2 a month on the free plan. The current prices are on the Store page; Apify's free plan includes $5 of credit a month, enough for about 2,500 tenders.

Links

Top comments (0)