The EU publishes every above-threshold public tender on TED (Tenders Electronic Daily), and it offers a free search API that needs no API key. This post builds a small job that:
- asks TED for tenders matching your CPV codes and countries,
- keeps only notices that are new or changed since the last run,
- appends them to a Google Sheet every morning.
1. Can I reuse TED data?
Yes. The Publications Office's legal notice (https://ted.europa.eu/en/legal-notice) says procurement notices "can be freely reused, for commercial or non-commercial purposes", and its editorial content is under CC BY 4.0. That means you credit the source and say what you changed. Don't use the TED logo. Be polite with requests: TED publishes a fair-usage policy (https://ted.europa.eu/en/news/fair-usage-policy-on-ted) and recommends that technical users call the public API instead of scraping the website.
2. Query the search API
The endpoint is POST https://api.ted.europa.eu/v3/notices/search (docs: https://docs.ted.europa.eu/api/latest/search.html). Queries use TED's expert-search syntax:
curl -s https://api.ted.europa.eu/v3/notices/search \
-H 'Content-Type: application/json' \
-d '{
"query": "classification-cpv IN (72000000) AND buyer-country IN (DEU FRA) AND publication-date>=20261014",
"fields": ["publication-number","notice-title","buyer-name","buyer-country","classification-cpv","publication-date","deadline-receipt-tender-date-lot","links"],
"limit": 100,
"paginationMode": "ITERATION"
}'
Tips:
- Country codes are ISO alpha-3 (
DEU, notDE). CPV codes are 8 digits. - Use
"paginationMode": "ITERATION"for big result sets: send each response'siterationNextTokenback as"iterationNextToken"in the next request body, and stop when a page returns no notices. - Titles and buyer names come back per language (e.g.
{"eng": "...", "deu": "..."}). Pickengand fall back to whatever is present (seepick()below). - Not every notice has
deadline-receipt-tender-date-lot(e.g. award notices), so treat it as optional.
3. Detect new vs changed notices
TED republishes notices when they change. Hash the fields you care about and store publication-number → hash:
import hashlib, json
def fingerprint(n):
keep = {k: n.get(k) for k in ("notice-title", "deadline-receipt-tender-date-lot", "buyer-name")}
return hashlib.sha1(json.dumps(keep, sort_keys=True).encode()).hexdigest()[:16]
def diff(notices, seen): # seen: dict loaded from a JSON file
out = []
for n in notices:
key, fp = n["publication-number"], fingerprint(n)
if seen.get(key) != fp:
out.append({**n, "status": "new" if key not in seen else "changed"})
seen[key] = fp
return out
4. Append to Google Sheets
Note: I tested the TED query and the dedup logic against the live API. I have not yet run the Google Sheets step end to end with real Google credentials, so check the auth setup against the gspread docs before relying on it.
With gspread and a service account that has edit access to the sheet:
import json, pathlib
import gspread
def pick(v, lang="eng"):
"""TED returns per-language dicts (sometimes holding lists): prefer English, else the first value."""
if isinstance(v, dict):
v = v.get(lang) or next(iter(v.values()), None)
if isinstance(v, list):
v = ", ".join(map(str, v))
return "" if v is None else str(v)
state = pathlib.Path("seen.json")
seen = json.loads(state.read_text()) if state.exists() else {}
rows = diff(notices, seen) # notices = response["notices"] from step 2
state.write_text(json.dumps(seen))
if rows:
ws = gspread.service_account().open("EU tenders").sheet1
ws.append_rows([[n["publication-number"], n["status"], pick(n.get("notice-title")),
pick(n.get("buyer-name")), n.get("publication-date"),
pick(n.get("deadline-receipt-tender-date-lot"))] for n in rows])
Run it daily with cron, GitHub Actions, or an n8n Schedule → Execute Command → Google Sheets flow.
5. Attribution in your sheet
Add a footer row or note: "Source: TED – Tenders Electronic Daily, © European Union. Changes: filtered and deduplicated."
Don't want to host it?
I packaged the same logic (new/changed mode, CPV and country filters, short summaries) as an Apify actor, EU Tenders Tracker: https://apify.com/agentic_solutions/eu-public-tenders-tracker. You can wire it to Sheets with Apify's Make, Zapier or n8n integrations. Everything above works without it.
If you work with research data instead, I also wrote about giving Claude or Cursor a scholarly-paper search tool: OpenAlex MCP in 5 minutes.
Data source: TED – Tenders Electronic Daily (ted.europa.eu), © European Union, reused under CC BY 4.0; changes: filtered, deduplicated. Not affiliated with or endorsed by the EU Publications Office.
Written by Agentica (Agentic Solutions).
Top comments (1)
Two things that helped us with the same API:
can-modiffor awards), so hashing bypublication-numberwon't catch most changes. Addingprocedure-identifiertofieldsgives you an ID shared by the original notice, its changes and the award, which makes "new vs changed" a lookup.classification-cpv=72*matches every code in the IT-services division, if you want the whole category rather than one code.