DEV Community

Data Drip
Data Drip

Posted on Edited on

How to monitor EU public tenders with the TED API (Python, free, no API key)

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:

  1. Query the official TED Search API v3: anonymous, free, no key.
  2. Filter to open calls for tender in your sector (CPV codes) and countries.
  3. Export a clean CSV with buyer, deadline, estimated value and link.
  4. 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 a iterationNextToken

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"
  }'
Enter fullscreen mode Exit fullscreen mode

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:

  • 72000000 IT services
  • 48000000 software
  • 45000000 construction
  • 79000000 business services
  • 33000000 medical equipment

Because trailing zeros mean "everything below", 72* covers all IT-service sub-codes.

Gotchas I hit

  • Multilingual fields. notice-title comes back as an object keyed by language ({"eng": "...", "spa": "..."}), and buyer-name often 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")
Enter fullscreen mode Exit fullscreen mode

Run it:

pip install requests
python ted_tenders.py
# e.g. "83 open tenders written to tenders.csv"
Enter fullscreen mode Exit fullscreen mode

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:

  1. Change since to yesterday (timedelta(days=1)) so each run only pulls new notices.
  2. Schedule it with cron (0 8 * * 1-5 python ted_tenders.py), GitHub Actions on a schedule: trigger, or n8n/Make.
  3. Send the CSV rows to Slack, email or a Google Sheet. Keep the id column 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 (default true)
    • publishedFrom/publishedTo (default: last 30 days)
    • expertQuery for 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 companyProfile you describe in plain words

Example input:

{ "keywords": "software", "countries": ["ES", "PT"], "noticeType": "competition", "onlyOpen": true, "maxResults": 100 }
Enter fullscreen mode Exit fullscreen mode

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}'
Enter fullscreen mode Exit fullscreen mode

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)