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?


