DEV Community

FedSignals Data
FedSignals Data

Posted on

How to Get Daily EU Tender Alerts from TED by Country and CPV Code (Python, No API Key)

Pull new EU calls for tenders from TED (Tenders Electronic Daily) for the countries and CPV codes you bid on, keep only the ones you can still bid on, and get only new notices each day.

Written with AI assistance and reviewed by the FedSignals Data team; we maintain these tools.

Public contracts above the EU thresholds have to be advertised on TED, the EU's official procurement journal. That covers the EU 27, the EEA and a few other countries, and it adds up to thousands of new notices every working day: calls for tenders, contract awards and prior information notices. If you sell IT services, construction, consulting or medical supplies to public buyers in Europe, this is where your next bid shows up.

The data is free and open, but turning it into a daily "new tenders for us" list takes some work:

  • TED's own saved searches and alerts need a TED account, and they land in an inbox. You can't easily send them to Slack, a CRM or a bid/no-bid script.
  • The TED Search API is open and needs no key, but you have to learn its query language, map eForms fields (buyer, lots, deadlines, values in several currencies) yourself, drop calls whose deadline has passed, and remember which notices you already handled.

We built the EU Tenders Monitor (TED) Actor on Apify to do that part, and this tutorial walks through it. It calls the official TED Search API (no scraping, no login), filters by buyer country, CPV code, keywords and value, converts values to EUR, and returns only notices it hasn't delivered before.

What you'll build

  • A daily list of new calls for tenders from the countries and CPV codes you pick, with buyer, deadline, value, lots and a link to the tender documents.
  • Only calls you can still bid on: closed calls are dropped by default.
  • A daily run that returns, and charges for, only notices it hasn't delivered before.
  • A Slack message (or webhook to your CRM) with each day's new tenders.

Step 1: Run it once by hand

Open apify.com/fedsignals/eu-tenders-monitor-ted and click Try for free. The prefilled input looks like this:

{
  "countries": ["DE", "FR"],
  "cpvCodes": ["72000000"],
  "noticeTypes": ["competition"],
  "publishedWithinDays": 3,
  "openOnly": true,
  "onlyNew": false,
  "maxItems": 10
}
Enter fullscreen mode Exit fullscreen mode

It asks for calls for tenders (competition) from German and French buyers, published in the last 3 days, in CPV 72000000 (IT services). Parent CPV codes include all their sub-codes, so 72000000 also matches software development, IT support and consulting. Countries accept DE, DEU or Germany. A misspelling such as Germnay stops the run before anything is charged, with a "Did you mean 'Germany'?" message. The run takes a few seconds, and with maxItems: 10 it costs at most about $0.03.

Step 2: Look at the output

Each row is one notice. Here is one from a real run (Oct 31, 2026):

{
  "noticeId": "07b9dee2-cfda-458f-9560-ea5dcd948bd6",
  "publicationNumber": "746434-2026",
  "title": "Germany – Geographic information systems (GIS or equivalent) – GIS Geodatenservice und WebGIS",
  "description": "Implementierung und Betrieb eines Geodatenservice und WebGIS als SaaS",
  "buyerName": "Fraport AG",
  "buyerCity": "Frankfurt am Main",
  "buyerCountry": "DEU",
  "buyerCountryIso2": "DE",
  "noticeType": "competition",
  "noticeSubtype": "cn-standard",
  "procedureType": "neg-w-call",
  "contractNature": "supplies",
  "cpvCodes": ["38221000", "71354100", "72300000", "72260000"],
  "placeOfPerformance": ["DE712"],
  "estimatedValue": null,
  "currency": null,
  "estimatedValueEur": null,
  "awardedValue": null,
  "awardedCurrency": null,
  "awardedValueEur": null,
  "valueBasis": "none",
  "publicationDate": "2026-10-28",
  "deadline": "2026-11-27T13:00:00Z",
  "latestDeadline": "2026-11-27T13:00:00Z",
  "deadlineType": "requestsToParticipate",
  "lotCount": 1,
  "lots": [
    {
      "lotId": "LOT-0000",
      "title": "GIS Geodatenservice und WebGIS",
      "estimatedValue": null
    }
  ],
  "documentsUrl": "https://xvergabe.de/NetServer/TenderingProcedureDetails?function=_Details&TenderOID=54321-Tender-1a0341cddbf-4611a43c9740a9fc&",
  "url": "https://ted.europa.eu/en/notice/-/detail/746434-2026",
  "source": "TED (Tenders Electronic Daily), © European Union",
  "scrapedAt": "2026-10-31T17:54:32Z"
}
Enter fullscreen mode Exit fullscreen mode

The award fields (awardedValue, awardedCurrency, awardedValueEur) are always null on calls for tenders like this one. They are only filled on award notices; see "A note on values" below.

A few fields worth knowing:

  • title is in English where TED provides a translation. description stays in the buyer's language.
  • deadline is the earliest lot deadline and latestDeadline the latest one, both ISO 8601 in UTC. Buyers set deadlines in local time, so a 14:00 CET deadline in Germany shows as 13:00:00Z. They're the same on single-lot notices.
  • deadlineType is tenders, or requestsToParticipate for restricted and negotiated procedures like this one, where you first apply to take part.
  • documentsUrl goes straight to the buyer's tender documents, and url to the notice on TED.
  • estimatedValue is the buyer's estimate in the published currency and estimatedValueEur the same amount converted with the ECB reference rate. Many buyers publish no value, as here, and valueBasis is then none.

Every run also writes a SUMMARY record to its key-value store. It shows the exact TED query, how many notices matched, what was skipped and why the run stopped:

{
  "query": "publication-date>=20261028 AND form-type IN (competition) AND buyer-country IN (DEU FRA) AND classification-cpv IN (72000000)",
  "totalMatching": 86,
  "scanned": 7,
  "delivered": 6,
  "skippedAlreadySeen": 0,
  "skippedClosed": 0,
  "skippedBelowMinValue": 0,
  "skippedNoValue": 0,
  "valuesWithoutEurRate": 0,
  "byNoticeType": {"competition": 6},
  "byCountry": {"DEU": 5, "FRA": 1},
  "stopReason": "spendingLimit",
  "maxItems": 10,
  "onlyNew": false,
  "openOnly": true,
  "partialFailures": [],
  "eurRatesLoaded": true,
  "firstRunForState": false,
  "requests": 1,
  "finishedAt": "2026-10-31T17:54:34Z"
}
Enter fullscreen mode Exit fullscreen mode

We ran this one with a $0.02 maximum charge, so it stopped cleanly after 6 notices (stopReason: spendingLimit) even though 86 matched. Without a charge limit it stops at maxItems.

Step 3: Call it from Python

This runs the Actor synchronously and prints each new call for tenders. It reads your Apify token from an environment variable (never paste it into code). maxTotalChargeUsd caps what the run can charge.

import os
import requests

ACTOR = "fedsignals~eu-tenders-monitor-ted"
URL = f"https://api.apify.com/v2/acts/{ACTOR}/run-sync-get-dataset-items"

run_input = {
    "countries": ["DE", "AT", "NL"],
    "cpvCodes": ["72000000", "48000000"],  # IT services, software packages (sub-codes included)
    "noticeTypes": ["competition"],        # calls for tenders only
    "publishedWithinDays": 3,
    "openOnly": True,                      # drop calls once every lot deadline has passed
    "onlyNew": True,                       # only notices this stateKey hasn't returned before
    "stateKey": "it-tenders-de-at-nl",     # one key per saved search
    "maxItems": 5,
}

resp = requests.post(
    URL,
    params={"maxTotalChargeUsd": 0.02},
    headers={"Authorization": f"Bearer {os.environ['APIFY_TOKEN']}"},
    json=run_input,
    timeout=300,
)
resp.raise_for_status()

for n in resp.json():
    value = f"€{n['estimatedValueEur']:,.0f}" if n.get("estimatedValueEur") else "no value published"
    print(f"{n['publicationNumber']}  {n['buyerCountryIso2']}  due {n['latestDeadline'] or 'n/a'}  {value}")
    print(f"    {n['buyerName']}: {n['title']}")
    print(f"    {n['url']}")
Enter fullscreen mode Exit fullscreen mode

Run it twice. The second run doesn't repeat anything from the first: onlyNew remembers every notice delivered for that stateKey, skips those for free, and moves on to notices you haven't seen. In a daily schedule that means each run returns only what TED published since the day before.

Two inputs we use a lot:

  • keywords: comma-separated words or phrases in any language, e.g. "cybersecurity, cybersécurité, Cybersicherheit". A notice matches if it contains any of them.
  • minValueEur: only notices worth at least this much in EUR. Keep includeWithoutValue: true (the default) unless you really want to drop the many notices that publish no value.

Step 4: Only tenders you can still bid on

With openOnly: true (the default), the Actor drops calls for tenders and prior information notices once every lot's deadline has passed. It compares latestDeadline, the last lot deadline, with the current UTC time when the run starts. So a multi-lot call stays in while any lot is still open. Check deadline too, because an earlier lot may already be closed. A date-only deadline counts as open through that day. Notices with no deadline, and contract awards, are always kept. Closed calls are skipped for free and counted in SUMMARY.skippedClosed. Set openOnly: false if you want closed calls too, for example for market research.

Step 5: Schedule it daily

  1. In the Apify console, click Save as a new task with your countries, CPV codes and keywords. Keep onlyNew: true and set a stateKey.
  2. Go to Schedules → Create new, use the cron expression 0 7 * * * (7:00 daily), set your timezone, and attach the task.

publishedWithinDays can stay at 3: thanks to onlyNew, the overlap between days costs nothing.

Step 6: Send new tenders to Slack or a webhook

Add a webhook for ACTOR.RUN.SUCCEEDED that points at a small function you host. The payload includes resource.defaultDatasetId:

import os
import requests

def handler(payload: dict) -> None:
    dataset_id = payload["resource"]["defaultDatasetId"]
    notices = requests.get(
        f"https://api.apify.com/v2/datasets/{dataset_id}/items",
        headers={"Authorization": f"Bearer {os.environ['APIFY_TOKEN']}"},
        timeout=60,
    ).json()
    if not notices:
        return  # nothing new today

    lines = [f"*{len(notices)} new EU tender{'s' if len(notices) != 1 else ''}*"]
    for n in notices[:30]:
        due = (n.get("latestDeadline") or "no deadline")[:10]
        value = f", €{n['estimatedValueEur']:,.0f}" if n.get("estimatedValueEur") else ""
        lines.append(f"• <{n['url']}|{n['publicationNumber']}> {n['buyerCountryIso2']} · {n['buyerName']}: {n['title'][:120]} (due {due}{value})")
    requests.post(os.environ["SLACK_WEBHOOK_URL"], json={"text": "\n".join(lines)}, timeout=30)
Enter fullscreen mode Exit fullscreen mode

A note on values and contract awards

Set noticeTypes to ["result"] to get contract award notices instead of calls, for market research on who buys what. On award notices the total awarded is in awardedValue, awardedCurrency and awardedValueEur, and valueBasis says which figure minValueEur was compared against.

One caveat: for framework agreements, TED's awarded total is often the framework maximum, which can be far more than what is actually spent. For example, notice 745274-2026 shows €4.01 billion against €407,237.86 payable. So treat large award values on framework agreements as ceilings, and expect a high minValueEur filter on award notices to let some of them through.

What it costs

Pay per event: $0.003 per notice ($3 per 1,000), plus Apify's $0.00005 run-start fee. A daily run that returns 20 new notices costs about $0.06, or about $1.80 a month. A run with nothing new costs only the start fee. Already-seen and closed notices are skipped for free. Set maxItems or a maximum charge per run to cap the cost; the run stops cleanly before going over it (start fee included). If the input is invalid, the run stops before any notice is charged: no results were charged; only Apify's small start fee ($0.00005) applied.

FAQ

Do I need a TED account or API key?
No. The TED Search API is open and needs no authentication, and the Actor doesn't log in anywhere.

Why is the title in English but the description in French?
TED translates notice titles into all EU languages; descriptions stay in the buyer's language. That's why keywords works best with a few translations.

Why did my run return no notices?
The status message says why: nothing new since the last run (with onlyNew), every match already past its deadline (with openOnly), nothing above your minValueEur, or no matches at all. Try more publishedWithinDays, fewer filters or a broader parent CPV code. The SUMMARY record shows the exact TED query.

Does it collect personal data?
Contact persons, emails and phone numbers are never requested, emails and phone numbers that buyers write into titles or descriptions are replaced with [redacted], and winner names aren't collected. Titles and descriptions are passed on as the buyer published them; in rare cases they name an official or a designer in their professional role.

Which countries are covered?
Buyers in the EU 27, the EEA (e.g. Norway) and other countries that publish on TED. publishedWithinDays goes back up to 90 days, and a run scans at most 50,000 notices.

Related


Last updated: Oct 31, 2026 (tested against Actor build 0.1.7).

FedSignals Data builds small public-data monitors on Apify. Source: TED (Tenders Electronic Daily), © European Union, https://ted.europa.eu, reused under the TED legal notice and Commission Decision 2011/833/EU, which allow commercial reuse with acknowledgement of the source. Exchange rates: European Central Bank euro reference rates. FedSignals Data is not affiliated with, or endorsed by, the European Union, the Publications Office of the EU or TED. Questions or feature requests: fedsignals.alerts@gmail.com.

Top comments (0)