Disclosure: I build data tools on Apify, including the hosted TED actor mentioned at the end. Everything up to that section works with nothing but Python and the free official API.
Every public contract above the EU thresholds has to be published in TED (Tenders Electronic Daily), the EU's official procurement journal. That means thousands of new notices every publishing day, from ministries, hospitals, universities and city councils across 30+ countries, in 24 languages.
If your company sells to the public sector, TED is a free sales pipeline. The problem is reading it. This tutorial shows how to:
- Query the official TED Search API v3: anonymous, free, no key.
- Filter to open calls for tender in your sector (CPV codes) and countries.
- Export a clean CSV with buyer, deadline, estimated value and link.
- Turn it into a daily alert.
1. The TED Search API in one request
The endpoint is POST https://api.ted.europa.eu/v3/notices/search. It takes a JSON body with:
-
query: TED's expert search syntax (the same as the "Expert search" box on ted.europa.eu) -
fields: which fields to return -
limit: page size (up to 100 per page here) -
paginationMode:"ITERATION"lets you walk large result sets with aiterationNextToken
Try it with curl:
curl -s -X POST https://api.ted.europa.eu/v3/notices/search \
-H "Content-Type: application/json" \
-d '{
"query": "classification-cpv=72* AND buyer-country IN (ESP PRT) AND form-type=competition",
"fields": ["publication-number", "notice-title", "buyer-name", "deadline-receipt-tender-date-lot"],
"limit": 5,
"paginationMode": "ITERATION"
}'
You get back notices, totalNoticeCount and an iterationNextToken for the next page.
The query syntax, briefly
| What you want | Expert query |
|---|---|
| IT services and every sub-code | classification-cpv=72* |
| Several sectors | (classification-cpv=72* OR classification-cpv=48*) |
| Buyers in Spain or Portugal |
buyer-country IN (ESP PRT). Alpha-3 codes, not ES/PT
|
| Calls for tender only (not awards) |
form-type=competition (awards are form-type=result) |
| Published since a date |
publication-date>=20261026 (YYYYMMDD) |
| Still open | deadline-receipt-tender-date-lot>=20261102 |
| Full-text keyword | FT~(cybersecurity) |
| Newest first | append SORT BY publication-number DESC
|
CPV codes are the EU's product and service taxonomy. A few useful parents:
-
72000000IT services -
48000000software -
45000000construction -
79000000business services -
33000000medical equipment
Because trailing zeros mean "everything below", 72* covers all IT-service sub-codes.
Gotchas I hit
-
Multilingual fields.
notice-titlecomes back as an object keyed by language ({"eng": "...", "spa": "..."}), andbuyer-nameoften only in the original language, as a list. Write a small helper that picks your language and falls back to the first value. - Lot-level fields are arrays. A tender with 5 lots can have 5 deadlines. For alerts, the first (or earliest) is usually what you want.
- Values are strings, and many notices don't publish an estimated value at all.
- Use ITERATION pagination for anything bigger than a page or two. Plain page numbers are capped for deep result sets.
2. A complete script: open IT tenders in Spain and Portugal → CSV
import csv, datetime, requests
API = "https://api.ted.europa.eu/v3/notices/search"
FIELDS = [
"publication-number", "notice-title", "buyer-name", "buyer-country",
"classification-cpv", "publication-date", "deadline-receipt-tender-date-lot",
"estimated-value-proc", "estimated-value-cur-proc",
]
def first(value, lang="eng"):
"""TED returns multilingual fields as {"eng": "..."} or {"spa": ["..."]}."""
if isinstance(value, dict):
v = value.get(lang) or next(iter(value.values()), None)
else:
v = value
if isinstance(v, list):
v = v[0] if v else None
return v
def search(query, max_results=500):
body = {"query": query, "fields": FIELDS, "limit": 100, "paginationMode": "ITERATION"}
out = []
while len(out) < max_results:
r = requests.post(API, json=body, timeout=60)
r.raise_for_status()
data = r.json()
out += data["notices"]
token = data.get("iterationNextToken")
if not token or not data["notices"]:
break
body["iterationNextToken"] = token
return out[:max_results]
today = datetime.date.today()
since = (today - datetime.timedelta(days=7)).strftime("%Y%m%d")
query = (
"classification-cpv=72* " # IT services and all child codes
"AND buyer-country IN (ESP PRT) " # alpha-3 country codes
"AND form-type=competition " # calls for tender, not awards
f"AND publication-date>={since} "
f"AND deadline-receipt-tender-date-lot>={today:%Y%m%d} " # still open
"SORT BY publication-number DESC"
)
rows = []
for n in search(query):
pub = n["publication-number"]
rows.append({
"id": pub,
"title": first(n.get("notice-title")),
"buyer": first(n.get("buyer-name")),
"country": first(n.get("buyer-country")),
"cpv": ";".join(sorted(set(n.get("classification-cpv") or []))),
"published": (first(n.get("publication-date")) or "")[:10],
"deadline": (first(n.get("deadline-receipt-tender-date-lot")) or "")[:10],
"value": first(n.get("estimated-value-proc")),
"currency": first(n.get("estimated-value-cur-proc")),
"url": f"https://ted.europa.eu/en/notice/-/detail/{pub}",
})
with open("tenders.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0]) if rows else ["id"])
w.writeheader()
w.writerows(rows)
print(f"{len(rows)} open tenders written to tenders.csv")
Run it:
pip install requests
python ted_tenders.py
# e.g. "83 open tenders written to tenders.csv"
When I ran it on 2 November 2026 it returned 83 open IT-services tenders from Spanish and Portuguese buyers published in the previous 7 days. They included an IT outsourcing contract for a Spanish social-security mutual (Asepeyo) with an estimated value of about EUR 49.4M. Your numbers will differ day to day.
3. Make it a daily alert
The simplest robust setup:
- Change
sinceto yesterday (timedelta(days=1)) so each run only pulls new notices. - Schedule it with cron (
0 8 * * 1-5 python ted_tenders.py), GitHub Actions on aschedule:trigger, or n8n/Make. - Send the CSV rows to Slack, email or a Google Sheet. Keep the
idcolumn as the dedupe key.
4. The hard part isn't fetching, it's reading
Once alerts work, you'll get 20-80 notices a day for a broad sector. Most are irrelevant, many are in languages you don't read, and the details that decide bid/no-bid are buried in the description. That's the part I wanted to automate.
5. Hosted option: TED scraper with AI bid summaries (what I built)
If you'd rather not maintain the script, I packaged it as an Apify actor: EU Public Tenders Scraper (TED) + AI Bid Summaries. It uses the same official API and adds:
-
Simple inputs:
keywords-
countries(2- or 3-letter codes) -
cpvCodes(parent codes include children) -
noticeType(competition,result,planning,all) -
onlyOpen(defaulttrue) -
publishedFrom/publishedTo(default: last 30 days) -
expertQueryfor raw TED syntax
-
A flat schema:
- title and translated title
- buyer name, country and city
- CPV codes
- publication date and submission deadline
- estimated value and currency
- framework flag, duration
- documents, submission, notice and PDF URLs
-
Optional AI bid triage with your own OpenAI or OpenRouter key:
- English title and short summary
- sector, key requirements
- red flags
- a 0-100 relevance score with a one-line reason, against a
companyProfileyou describe in plain words
Example input:
{ "keywords": "software", "countries": ["ES", "PT"], "noticeType": "competition", "onlyOpen": true, "maxResults": 100 }
Or call it from code (replace <APIFY_TOKEN> with your own token):
curl -X POST "https://api.apify.com/v2/acts/data-drip~eu-tenders-ted-ai/run-sync-get-dataset-items?token=<APIFY_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"keywords":"software","countries":["ES","PT"],"noticeType":"competition","onlyOpen":true,"maxResults":100}'
Pricing is pay per result:
- $0.003 per notice ($3 per 1,000)
- $0.006 per AI tender summary, plus your own LLM tokens
So 1,000 notices with AI cost $9 plus tokens. Apify's free plan includes $5 of monthly usage, enough for ~1,666 notices without AI. You can schedule it with Apify Schedules and push results to Google Sheets, Slack, Zapier, Make or n8n. It also works from AI assistants via the Apify MCP server.
Limitations (same as TED itself):
- Only above-threshold notices, so smaller national tenders (e.g. many on Spain's PLACSP) aren't in TED.
- No contact persons and no award-winner details.
Wrap-up
- The TED API is one of the best free B2B data sources in Europe: official, anonymous and structured.
-
classification-cpv=XX*+buyer-country IN (...)+form-type=competition+ a deadline filter gets you 90% of the way to a useful alert. - Fetching is easy. Triage is where the time goes, so automate that next.
Questions about the query syntax? Ask in the comments. Happy to help debug queries.
Top comments (0)