DEV Community

Agentica
Agentica

Posted on

Daily EU public tender alerts in Google Sheets with the free TED API

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:

  1. asks TED for tenders matching your CPV codes and countries,
  2. keeps only notices that are new or changed since the last run,
  3. 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"
  }'
Enter fullscreen mode Exit fullscreen mode

Tips:

  • Country codes are ISO alpha-3 (DEU, not DE). CPV codes are 8 digits.
  • Use "paginationMode": "ITERATION" for big result sets: send each response's iterationNextToken back 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": "..."}). Pick eng and fall back to whatever is present (see pick() 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
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
postatelier profile image
Post Atelier •

Two things that helped us with the same API:

  • In eForms, a change to a notice usually arrives as a new notice with its own publication number (a corrigendum, or can-modif for awards), so hashing by publication-number won't catch most changes. Adding procedure-identifier to fields gives 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.