Every public tender above the EU thresholds, from a German city's software project to a French hospital's equipment purchase, is published in TED (Tenders Electronic Daily), the EU's official procurement journal. TED has a free, official search API. The catch is its expert query syntax, its nested multilingual fields and a single notice that can contain several lots with different deadlines and values.
This post shows a script that answers one practical question: "Which open tenders match what we sell, in the countries we serve, and are still biddable?" You get flat JSON and a CSV.
Disclosure: I built the EU Tenders (TED) API actor used below. It calls the official TED Search API v3 and only returns organisations, never contact persons.
The 20-line version
# pip install apify-client
import csv, os
from apify_client import ApifyClient
client = ApifyClient(os.environ["APIFY_TOKEN"])
run_input = {
"keywords": ["software"],
"countries": ["DEU", "AUT"], # ISO alpha-3 buyer countries
"cpvCodes": ["72", "48"], # IT services, software packages
"noticeTypes": ["competition"], # open calls for tenders
"publishedWithinDays": 7,
"deadlineAfter": "today", # skip expired tenders
"maxResults": 50,
}
run = client.actor("fast_data_asap/eu-tenders-ted-api").call(run_input=run_input)
notices = list(client.dataset(run["defaultDatasetId"]).iterate_items())
notices.sort(key=lambda n: n.get("deadline") or "9999")
fields = ["deadline", "buyerName", "buyerCity", "buyerCountry", "title", "estimatedValue", "currency", "url"]
with open("tenders.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
w.writeheader()
w.writerows(notices)
print(len(notices), "open tenders written to tenders.csv")
No browser, no HTML parsing. The actor turns the inputs into a TED expert query, follows pagination and flattens each notice into one row.
What comes back
These are real rows from test runs on 8 and 9 November 2026 (shortened):
| Deadline | Buyer | What | Est. value |
|---|---|---|---|
| 02.12.2026 | MD-IT GmbH, Berlin | Framework agreement: AI for hospital billing review, compliance, AI governance (3 lots) | €121,000,000 |
| 07.12.2026 | Stadt Brandis | Development of OpenCore modules for the PartheCloud | €625,600 |
| 08.12.2026 | BWI GmbH, Bonn | Framework contract, notice "1961-JC-NoCodeDB" (software packages) | not published |
Every row has the same flat fields:
{
"noticeId": "779062-2026",
"title": "Germany – Programming services of application software – Entwicklungsleistungen für OpenCore-Module der PartheCloud",
"buyerName": "Stadt Brandis",
"buyerCountry": "DEU",
"buyerCity": "Brandis",
"cpvMain": "72212000",
"cpvCodes": ["72212000", "72262000", "72263000"],
"formType": "competition",
"procedureType": "open",
"estimatedValue": 625600,
"currency": "EUR",
"publishedAt": "2026-11-09",
"deadline": "2026-12-07T12:00:00+01:00",
"lotsCount": 1,
"url": "https://ted.europa.eu/en/notice/-/detail/779062-2026"
}
Two details that save you work:
-
deadlineis the earliest lot deadline as an ISO timestamp, so sorting and "days left" are one line. -
estimatedValueis summed across lots. It isnullwhen the buyer did not publish a value (BWI above), which is common.
Choosing filters that actually match your business
CPV prefixes are the most useful filter. CPV is the EU's product vocabulary, and a prefix matches everything below it:
| Prefix | Covers |
|---|---|
72 |
IT services (consulting, development, support) |
48 |
Software packages and information systems |
79 |
Business services (consulting, marketing, translation) |
45 |
Construction work |
90 |
Cleaning, waste and environmental services |
33 |
Medical equipment |
Keywords are matched in full text in any language, so ["software", "Software", "logiciel"] widens the net across countries. Start broad, look at a week of results, then add minValueEur if small contracts are noise for you. Notices without a published value are kept, so you do not silently lose them.
Want to know who wins? Switch noticeTypes to ["result"]. Contract award notices show which buyer bought what, and often for how much: useful for competitor research and pricing.
Running it every morning
For a daily alert set publishedWithinDays to 2 (one day overlap as a safety net) and de-duplicate on noticeId:
import json, pathlib
seen_file = pathlib.Path("seen.json")
seen = set(json.loads(seen_file.read_text())) if seen_file.exists() else set()
new = [n for n in notices if n["noticeId"] not in seen]
seen_file.write_text(json.dumps(sorted(seen | {n["noticeId"] for n in new})))
for n in new:
print(f'{n["deadline"][:10]} {n["buyerName"]}: {n["title"][:90]}\n {n["url"]}')
Schedule the script with cron or a GitHub Action, or use an Apify schedule and pipe the dataset into Slack or a sheet. If you prefer no-code, our free n8n sales-signal workflow does the same for German tenders and can take TED as a third branch.
Limits
- TED lists notices above the EU thresholds (plus voluntary ones). Smaller national tenders are on national portals; for Germany there is our German Public Tenders actor.
- Titles and summaries come in the notice language. The actor prefers your
language(English by default) where the notice is multilingual. - This is a search tool, not procurement or legal advice. Always read the official notice before bidding.
What it costs
$0.002 per notice returned, and empty runs are free. The 50-notice daily run above costs at most $0.10; a typical "last two days, two countries, two CPV codes" run returns far fewer rows.
Try it
- EU Tenders (TED) API on Apify
- Agent users: the free
find-public-tendersskill lets an AI agent run this search for you.
Question for bid managers: which filter do you rely on most, CPV codes or keywords, and how many false positives do you accept per day before an alert becomes noise?
Top comments (0)