DEV Community

Cover image for Free EU tender alerts from the TED API (one Python file, no account)
Philipp Primisser
Philipp Primisser

Posted on AI-assisted

Free EU tender alerts from the TED API (one Python file, no account)

The EU publishes thousands of public procurement notices every week on TED (Tenders Electronic Daily). Commercial tender alert services usually charge a subscription. If you already know which CPV codes you bid on, the official Search API is enough for a personal alert.

I put that into one Python file with no dependencies beyond the standard library: ted-tender-alerts (MIT).

python ted_alerts.py --cpv 72 48 --country AUT --days 7 --lang de
Enter fullscreen mode Exit fullscreen mode
22 matching notices, 22 new.
- Commvault
  … | AUT | 21,000,000 EUR | deadline 2026-11-09 (32 d)
  https://ted.europa.eu/de/notice/-/detail/696492-2026
- MA 9 – Digitalisierung 2027-2031
  Magistrat der Stadt Wien … | AUT | deadline 2026-11-17 (40 d)
  …
Enter fullscreen mode Exit fullscreen mode

(Real output from 8 October 2026. Run it again and you get 22 matching notices, 0 new.)

What the API gives you

TED Search API v3 is anonymous. One POST to https://api.ted.europa.eu/v3/notices/search with a query string in TED's expert-search language:

query = (
    "publication-date>=20261001"
    " AND buyer-country IN (AUT DEU)"
    " AND notice-type IN (cn-standard cn-social cn-desg subco qu-sy)"
    " AND (classification-cpv=72* OR classification-cpv=48*)"
    " SORT BY publication-date DESC"
)
body = {"query": query, "fields": [...], "page": 1, "limit": 100,
        "paginationMode": "PAGE_NUMBER", "scope": "ALL"}
Enter fullscreen mode Exit fullscreen mode

CPV prefixes work: 72 matches every code under 72000000 (IT services). Countries use ISO alpha-3 (AUT, DEU). --print-query prints the string so you can paste it into TED's expert search and compare.

The script is gentle with the API: it pauses 0.5 s between result pages and stops after 20 pages (100 notices each) by default.

Only the new ones

A state file (ted_state.json) remembers publication numbers already reported. Every run compares the current result to that set and prints only the difference. Notices older than 120 days drop out of the state, so the file stays small.

Same idea for an Atom feed of the latest 100, a CSV you append to, a Markdown table, or an e-mail through your own SMTP server (SMTP_HOST, MAIL_TO, … — the password is never printed or written).

Run it daily for free on GitHub Actions

The repository includes an example workflow (examples/daily-ted-alerts.yml). Copy it to .github/workflows/ in your own copy of the repo and it runs every weekday morning, commits the state and the Atom feed back, and optionally e-mails you. Edit the CPV/country filters in the YAML; add SMTP secrets if you want mail. Point a feed reader at the raw URL of data/feed.xml (public repos) or just open data/latest.md.

Finding your CPV codes

Look at five tenders you would have bid on and note their CPV codes (they're on every TED notice). Shorter prefixes catch more: 72 is all IT services, 7222 only IT consultancy. Start broad, then narrow once you see the noise.

Legal note

Data comes from TED – Tenders Electronic Daily, © European Union. Notices may be reused free of charge with attribution (TED legal notice). The script adds the attribution line to every output.

Try it

git clone https://github.com/philippprimisser-max/ted-tender-alerts
cd ted-tender-alerts
python ted_alerts.py --cpv 72 --country AUT --days 3
Enter fullscreen mode Exit fullscreen mode

AI-assisted: a model helped with the wording. The sample run and the 22-notice count are from my own machine on 8 October 2026. Not affiliated with the EU Publications Office.

Top comments (2)

Collapse
 
alewx profile image
alex •

how does the script handle API rate limits and pagination when a CPV query returns thousands of entries, and does it implement any retry/backoff for the occasional 500 from the TED endpoint also consider persisting the last seen notice id to avoid duplicate alerts after a restart

Collapse
 
prime619 profile image
Philipp Primisser •

Good questions. I went back to the code instead of answering from memory.

Pagination: it uses TED's PAGE_NUMBER mode, 100 notices per page, newest first. It stops on a short page, at totalNoticeCount, or after --max-pages (default 20, so 2,000 notices), with 0.5 s between pages, so at most ~120 requests a minute against TED's 700/min fair-use limit. For a daily alert with --days 1–3 that's usually plenty. If a broad CPV prefix matched more than the cap, the oldest matches used to be dropped silently; it now prints a warning when TED reports more matches than were fetched. Page-number mode also tops out at 15,000 results on TED's side; for bulk pulls the ITERATION mode with iterationNextToken would be the right tool, and the script doesn't use it yet.

Retries: 429, any 5xx and network errors are retried 4 times with exponential backoff. Other 4xx fail straight away. Until today it ignored Retry-After and had no jitter; both are in now.

Duplicates after a restart: instead of a single last-seen ID it keeps every publication number it has already reported in a JSON state file (120 days, written via temp file + rename), so it doesn't depend on the order TED returns results in. Restarts are fine as long as the file survives, which is why the GitHub Actions example commits it back. Two weak spots were a failed e-mail step (traceback, state not saved) and a corrupt state file (crash). A failed e-mail is now a clean error with the state untouched, so the next run retries, and a corrupt state file stops with a clear message.

Your comment pushed me to fix these, thanks. Commit with tests: github.com/philippprimisser-max/te...

Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more