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,canadabuysorted),notice_id,solicitation_number,url. -
title,buyer(department or contracting authority),buyer_unit,country(ISO code) andregion(state, province or buyer city). -
notice_type,category(goods, services, construction on CanadaBuys; supplies, services, works on TED) andstatus(open, closed, awarded). -
naics,unspscandcpvcodes where the source publishes them, andvaluewithcurrencywhere the buyer publishes one. -
published_dateandcloses_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 incontacts.
This week's tenders in your codes
pip install apify-client
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])
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/...",
}
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
buyerandbuyer_unitto 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
openOnlyoff 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
incrementalon. -
500 per source per run by default, newest first; the run status says when the cap was hit. Set
maxResultsPerSourcehigher, 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.
Top comments (0)