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:

  • deadline is the earliest lot deadline as an ISO timestamp, so sorting and "days left" are one line.
  • estimatedValue is summed across lots. It is null when 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

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?