DEV Community

Juan de Pablos
Juan de Pablos

Posted on

Watching EU Public Tenders With the TED API (and Never Alerting on the Same One Twice)

Every public buyer in the EU has to publish contracts above the EU thresholds on TED (Tenders Electronic Daily), the procurement supplement of the EU's Official Journal. On Tuesday 22 September 2026 that was 1,648 new contract notices, in 24 languages. TED publishes every working day, and its search API is public. No key, no account.

This month I built a tender watcher on top of it: you describe what you sell, it runs once per working day and tells you what appeared since the last run. Querying TED was the easy part. Deciding what counts as "new" was not, and two of my mistakes are in this post.

The code is plain Node 18+ with the built-in fetch.

One POST, no key

The endpoint is POST https://api.ted.europa.eu/v3/notices/search. The query uses TED's expert search syntax and you list the fields you want back:

const res = await fetch('https://api.ted.europa.eu/v3/notices/search', {
  method: 'POST',
  headers: { 'content-type': 'application/json', accept: 'application/json' },
  body: JSON.stringify({
    query: 'publication-date>=20260909 AND notice-type IN (cn-standard) '
      + 'AND buyer-country IN (IRL) AND FT~("software") SORT BY publication-number DESC',
    fields: ['publication-number', 'publication-date', 'buyer-name',
      'deadline-receipt-tender-date-lot', 'deadline-receipt-tender-time-lot'],
    limit: 250,
    paginationMode: 'ITERATION',
    scope: 'ALL',
  }),
});
const { notices, totalNoticeCount, iterationNextToken } = await res.json();
Enter fullscreen mode Exit fullscreen mode

cn-standard is the regular contract notice, the call for tenders that comes with a submission deadline. Countries are ISO 3166 alpha-3 (ESP, DEU, IRL), so if your users type ES, convert it first.

Two things in the syntax cost me time:

  • FT~("software" OR "cloud") is accepted and returns the wrong thing. Over the same two weeks it gave me 120 notices, while FT~("software") alone gave 1,586. Chain separate clauses instead: (FT~("software") OR FT~("cloud")) returned 1,869.
  • A " or a \ inside a keyword does not raise an error. It drops the filter. A keyword with a backslash in it came back with all 28,072 notices of the month. I strip both characters before building the query.

Paginate with ITERATION, sort by publication number

limit tops out at 250. Page-number mode stops at 15,000 results (page times limit). ITERATION mode has no such ceiling: every response carries an iterationNextToken that you send back until it runs out. Slightly trimmed from the real code:

async function search({ query, maxNotices }) {
  const notices = [];
  let token = null;
  let total = 0;
  while (notices.length < maxNotices) {
    const body = { query, fields: FIELDS, limit: 250, paginationMode: 'ITERATION', scope: 'ALL' };
    if (token) body.iterationNextToken = token;
    const page = await post(body); // 400 ms between requests, retries on 429/5xx
    total = page.totalNoticeCount ?? total;
    notices.push(...page.notices);
    token = page.iterationNextToken;
    if (!token || !page.notices.length || notices.length >= total) break;
  }
  const kept = notices.slice(0, maxNotices);
  return { notices: kept, total, truncated: total > kept.length };
}
Enter fullscreen mode Exit fullscreen mode

With SORT BY publication-date, ITERATION returned 568 of 673 matching notices. Many notices share a publication date, and my best guess is that the cursor loses some of the ties. Sorted by publication-number DESC I got all 673, still newest first.

Two smaller things. The fair-use policy talks about 700 requests per minute per IP, but I got a 429 from nginx after about 10 requests in 2 seconds, so I keep 400 ms between calls and back off on 429 and 5xx. And a 200 response can carry timedOut: true, which means partial results. I treat it as an error and retry, because watching a list with holes in it is worse than waiting.

Where the deadline lives

For someone preparing a bid, the deadline is the most important field, and it is not one field. Date and time come separately, each with its own UTC offset, one value per lot:

"publication-number": "656715-2026",
"deadline-receipt-tender-date-lot": ["2026-10-26+01:00"],
"deadline-receipt-tender-time-lot": ["16:00:00+01:00"]
Enter fullscreen mode Exit fullscreen mode

I glue date and time into one instant, and when a notice has several lots I take the earliest deadline that has not passed yet.

The bigger surprise was notices with no tender deadline at all. In a sample of 1,000 contract notices, 9% had none, only a deadline to request to participate (restricted procedures). Calls based on a prior information notice only carry a deadline for expressions of interest. So I read three pairs of fields, in this order, and keep track of which one I used:

const DEADLINE_SOURCES = [
  ['tender', 'deadline-receipt-tender-date-lot', 'deadline-receipt-tender-time-lot'],
  ['request-to-participate', 'deadline-receipt-request-date-lot', 'deadline-receipt-request-time-lot'],
  ['expression-of-interest', 'deadline-receipt-expressions-date-lot', 'deadline-receipt-expressions-time-lot'],
];
Enter fullscreen mode Exit fullscreen mode

Without the fallback, one tender in eleven showed up with a blank deadline.

The actual problem: what is new?

Say you watch "software" in Spain. There are open tenders every single day. An alert that fires whenever the search has results fires every day, mostly with yesterday's list. The alert has to be about what changed since the last run.

So the watcher keeps, per watch, the publication numbers it has already checked, in a key-value store, and compares. That sounds like five lines of code. It ended up needing four rules.

1. The first run is a baseline, not an alert

On the first run there is nothing to compare against, so everything that matches is "unseen", including tenders published two weeks ago. The default example watch found 170 matching notices on its first run. Sending those as 170 new tenders would be the worst possible first impression.

So the first run records and alerts on nothing:

const isBaseline = tracking && !prev; // no saved state for this watch yet

export function markNovelty(tenders, { seen, canFlag, noveltyFrom }) {
  return tenders.map((t) => ({
    ...t,
    isNew: canFlag // false on the baseline run
      && !!t.publicationNumber
      && !seen.has(t.publicationNumber)
      && (!noveltyFrom || !t.publicationDate || t.publicationDate >= noveltyFrom),
  }));
}
Enter fullscreen mode Exit fullscreen mode

The date check is a second guard: only a notice published from 7 days before the watch started can ever be new, and the 7 days cover notices that reach the index later than their publication date. A notice without an id is never new, because there is no way to track it.

2. A watch is its search, not its display filters

The state is stored under a key derived from the watch, and the question is what goes into that key. My answer: everything that changes which notices TED returns, and nothing else.

export function watchKey({ keywords, cpvCodes, countries, noticeTypes }) {
  const kw = keywords.map((k) => k.toLowerCase()).sort();
  const cpv = [...cpvCodes].sort();
  const cc = [...countries].sort();
  const signature = JSON.stringify([kw, cpv, cc, [...noticeTypes].sort()]);
  const hash = createHash('sha1').update(signature).digest('hex').slice(0, 10);
  const label = kw[0] ?? (cpv[0] ? `cpv-${cpv[0]}` : cc.join('-'));
  return `w-${slugify(label)}-${hash}`; // slugify: lowercase, a-z, 0-9 and dashes
}
Enter fullscreen mode Exit fullscreen mode

Minimum days left, minimum value, the result cap and the watch name stay out of the key. If they were in, a user who changes "at least 10 days left" to 15 would start a brand new watch. A new watch starts with a baseline, and a baseline alerts on nothing, so that day's new tenders would never be reported.

Minimum value is a display filter for a less obvious reason. TED can filter estimated-value-proc on the server, but in the same 1,000-notice sample 58% published no value at all, and a server-side filter would drop every one of them. I filter in code instead, keep the notices without a value, and convert currencies first, because 1,000,000 PLN is not 1,000,000 EUR.

The first bug was here, and a test caught it. The hash was built from the sorted keywords, but the readable prefix used keywords[0] in the order the user typed them. ["software", "cloud"] and ["cloud", "software"] gave w-software-... and w-cloud-...: the same search stored as two watches, the second one starting over with a silent baseline. The prefix now comes from the sorted list.

3. Remember everything you fetched, not only what you showed

The second mistake I brought with me. The tender watcher started as a copy of one I had built for trademark filings, where "seen" meant "shown to the user". For tenders that rule is wrong, because the list changes by itself.

Tenders close, daysLeft drops every day, and the list is capped (100 by default). Picture a tender published ten days ago sitting at position 140, below the cap, never shown. Over the next days newer tenders close, it moves up into the visible 100, and since it was never "seen", it gets announced as new ten days late. With that rule the same happens when a user relaxes a filter: lower "at least 10 days left" to 0 and the tenders that filter used to hide come back as new.

The fix was to separate the two ideas. seen is every id TED returned, shown or not. New means unseen and visible after the filters:

const open = tenders.filter((t) => !t.deadline || Date.parse(t.deadline) >= nowMs);
const visible = open.filter((t) => (t.daysLeft == null || t.daysLeft >= minDaysLeft) && meetsValue(t, minValue));
const fresh = visible.filter((t) => t.isNew);
return {
  tenders: [...fresh, ...visible.filter((t) => !t.isNew)].slice(0, maxTenders),
  newTenders: fresh.map(alertView),
};
Enter fullscreen mode Exit fullscreen mode

A new tender that your filters hide is not an alert, and because it is remembered, it will not turn into one when you relax the filter later. The cap cuts the list, never the alert.

4. Save the state before you notify

The order of the last three steps decides what a failure looks like (condensed from the real code):

// 1. save first: old ids + everything fetched in this run
try {
  await store.setValue(key, next);
  persisted = true;
} catch (err) {
  log.warning(`State write failed for "${label}" (${err.message}), no alert this run.`);
}
// 2. deliver; if delivery fails, undo the state so nothing counts as seen
try {
  await Actor.pushData(result);
} catch (err) {
  if (persisted) await store.setValue(key, base ?? null);
  throw err;
}
// 3. only now alert (in my case, the alert is also what gets billed)
if (newCount > 0 && persisted && !isBaseline) await chargeSafe(Actor, log, 'match-found');
Enter fullscreen mode Exit fullscreen mode

If you notify first and the save fails, the next run compares against the old state and sends the same alert again. For me that would also mean charging twice for the same tender. With the state saved first, the worst case goes the other way: the save fails, this run skips the alert, and the next run, which still has not seen those tenders, sends it. A late alert is much cheaper than a repeated one. And if the delivery itself fails, the state goes back to what it was, so nothing is marked as seen that the user never got.

One last decision in that line: the alert fires once per watch and run, never once per tender. "software" across the EU produced 137 new contract notices on that one Tuesday. Nobody wants 137 messages, and nobody wants an invoice that depends on how busy public buyers were that week.

If you would rather not build it

I packaged all of this as an Apify Actor, EU Tender Watch. You give it keywords, CPV codes and countries, run it on a schedule once per working day, and it returns the open tenders with deadline, days left, estimated value and links, plus a newTenders list with only what appeared since the last run. The watch state stays in a key-value store in your own Apify account. If you are building your own, everything above applies without it.

If you have built alerts on another public data feed, how did you handle the first run? A silent baseline like this one, or a single digest of everything that was already there?


Notice data in this post comes from TED (Tenders Electronic Daily), https://ted.europa.eu, © European Union, 1998-2026, reused under Commission Decision 2011/833/EU. The notice on TED is always the authoritative version.

Top comments (0)