DEV Community

OpenData Forge
OpenData Forge

Posted on Originally published at opendata-forge.hashnode.dev on

Monitor EU public tenders (TED) for your keywords with Apify and n8n

Disclosure: I built and maintain the TED Actor used here. It's a paid Actor on the Apify Store ($1.50 per 1,000 results), so I earn a share when you run it. This article was written with AI assistance, and every command and workflow was run and checked before publishing.

Every year the EU's Tenders Electronic Daily (TED) publishes hundreds of thousands of procurement notices. If you sell software, consulting or hardware to the public sector, a 9 a.m. message with only the new, still-open tenders matching your keywords saves hours of portal searching.

In this tutorial we build that alert:

  1. An Apify Actor queries the official TED Search API v3 (no scraping, no TED API key).
  2. An n8n workflow runs it every weekday, drops notices you've already seen and anything already closed, and sends you a digest (email, Slack, Telegram, whatever you prefer).

Total cost: the Actor charges $1.50 per 1,000 results, so 50 results every weekday is roughly $1.70 a month, which fits inside the $5 monthly credit on Apify's free plan. Add n8n (free if self-hosted, or an n8n Cloud plan).

1. Try the Actor

Open the EU Public Tenders (TED) Actor on Apify (create a free account if you don't have one) and run it with this input:

{
  "keywords": "ciberseguridad",
  "countries": ["ESP", "PRT"],
  "publishedAfter": "2026-10-01",
  "maxResults": 20
}

Enter fullscreen mode Exit fullscreen mode

Countries are ISO-3 codes. You can also pass cpvCodes, e.g. ["72000000"] for IT services. When I ran this on 17 Oct 2026, it returned 19 notices in about 2 seconds. Each item looks like this:

{
  "publicationNumber": "707641-2026",
  "title": "Spain – IT services: consulting, software development, Internet and support – Servicio Integral de Ciberseguridad SIC27",
  "buyer": "Compras de la Corporación de Radio y Televisión Española S.A.",
  "country": "ESP",
  "publicationDate": "2026-10-14+02:00",
  "deadline": "2026-11-19+01:00",
  "cpv": ["72000000"],
  "noticeType": "cn-standard",
  "url": "https://ted.europa.eu/en/notice/-/detail/707641-2026"
}

Enter fullscreen mode Exit fullscreen mode

Two things I learned from the real output:

  • noticeType matters. Keyword searches also return contract award notices (can-*), which are useless for bidding. Keep cn-* (contract notices).
  • Full-text search is broad."ciberseguridad" also matched an EU furniture framework whose text mentions cybersecurity, and buyer-country matches multi-country EU notices. Add CPV codes if you want precision.

2. Get your Apify API token

In Apify Console go to Settings → API & Integrations and copy your personal API token. In n8n, store it as an environment variable APIFY_TOKEN (self-hosted). On n8n Cloud, or if your instance blocks $env access, create a Query Auth credential instead (name token, value = your token) and select it in the HTTP node. Don't hard-code it in the workflow.

3. Import the n8n workflow

Copy the workflow JSON from the appendix at the end of this post and paste it onto an empty n8n canvas (Ctrl/Cmd+V), or save it as a .json file and use Import from File. It has four nodes:

Node What it does
Every weekday 08:00 Schedule trigger, cron 0 8 * * 1-5
Run TED Actor POST https://api.apify.com/v2/acts/opendata-forge~eu-public-tenders-ted-search/run-sync-get-dataset-items?token=…, which runs the Actor and returns its dataset in one call
Only new open tenders Code node: keeps cn-* notices whose deadline hasn't passed and that haven't been alerted before (stored in workflow static data)
Build digest Code node: turns the items into a subject and text body

The Actor ID (opendata-forge~eu-public-tenders-ted-search) is already filled in. The request body uses n8n expressions so it always looks back two days:

{
  "keywords": "ciberseguridad",
  "countries": ["ESP", "PRT"],
  "publishedAfter": "{{ $now.minus({days: 2}).toFormat('yyyy-MM-dd') }}",
  "maxResults": 50
}

Enter fullscreen mode Exit fullscreen mode

The dedupe logic in Only new open tenders :

const seen = $getWorkflowStaticData('global');
seen.ids = seen.ids || [];
const today = new Date().toISOString().slice(0, 10);
const fresh = [];
for (const item of $input.all()) {
  const n = item.json;
  if (!n.noticeType || !n.noticeType.startsWith('cn')) continue; // contract notices only
  if (n.deadline && n.deadline.slice(0, 10) < today) continue; // already closed
  if (seen.ids.includes(n.publicationNumber)) continue; // already alerted
  seen.ids.push(n.publicationNumber);
  fresh.push({ json: n });
}
seen.ids = seen.ids.slice(-2000);
return fresh;

Enter fullscreen mode Exit fullscreen mode

Static data only persists for active (scheduled) workflows, not manual test runs, so expect repeats while you're testing.

4. Add your delivery node

After Build digest , add a Send Email , Slack or Telegram node and map {{ $json.subject }} and {{ $json.text }}. To skip empty days, add an IF node on {{ $json.count }} > 0.

The digest from my test run (5 of 19 notices survived the filter):

• Spain – IT services: consulting, software development, Internet and support – Servicio Integral de Ciberseguridad SIC27
  Compras de la Corporación de Radio y Televisión Española S.A. | deadline: 2026-11-19 | value: see notice
  https://ted.europa.eu/en/notice/-/detail/707641-2026

Enter fullscreen mode Exit fullscreen mode

5. How I tested this

  • Ran the Actor code locally against the live TED API with the input above (19 notices).
  • Ran the n8n workflow (n8n 1.123) from the CLI, with the HTTP node pointed at a local mock that returned exactly that dataset. The workflow succeeded and the filter kept 5 open contract notices. The workflow JSON below imports cleanly. I did not call Apify's hosted endpoint in this test, because that needs your token.

Variations

  • One workflow per client or sector, each with different keywords and CPV codes.
  • Write rows to Google Sheets or Airtable for a tender pipeline.
  • Prefer Spanish national tenders? The same pattern works with the BOE/BORME daily search Actor and sections: ["5A"].

Data: TED – Tenders Electronic Daily, © European Union, reused under Commission Decision 2011/833/EU. This tutorial is not affiliated with the Publications Office of the EU.

Appendix: workflow JSON

{
 "name": "TED tender alerts (Apify + n8n)",
 "nodes": [
  {
   "parameters": {
    "rule": {
     "interval": [
      {
       "field": "cronExpression",
       "expression": "0 8 * * 1-5"
      }
     ]
    }
   },
   "name": "Every weekday 08:00",
   "type": "n8n-nodes-base.scheduleTrigger",
   "typeVersion": 1.2,
   "position": [
    0,
    0
   ]
  },
  {
   "parameters": {
    "method": "POST",
    "url": "https://api.apify.com/v2/acts/opendata-forge~eu-public-tenders-ted-search/run-sync-get-dataset-items",
    "sendQuery": true,
    "queryParameters": {
     "parameters": [
      {
       "name": "token",
       "value": "={{ $env.APIFY_TOKEN }}"
      }
     ]
    },
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={\n \"keywords\": \"ciberseguridad\",\n \"countries\": [\"ESP\", \"PRT\"],\n \"publishedAfter\": \"{{ $now.minus({days: 2}).toFormat('yyyy-MM-dd') }}\",\n \"maxResults\": 50\n}",
    "options": {
     "timeout": 300000
    }
   },
   "name": "Run TED Actor",
   "type": "n8n-nodes-base.httpRequest",
   "typeVersion": 4.2,
   "position": [
    260,
    0
   ]
  },
  {
   "parameters": {
    "jsCode": "// Keep only open calls for tender, drop ones already seen in previous runs\nconst seen = $getWorkflowStaticData('global');\nseen.ids = seen.ids || [];\nconst today = new Date().toISOString().slice(0, 10);\nconst fresh = [];\nfor (const item of $input.all()) {\n const n = item.json;\n if (!n.noticeType || !n.noticeType.startsWith('cn')) continue; // contract notices only (no awards)\n if (n.deadline && n.deadline.slice(0, 10) < today) continue; // already closed\n if (seen.ids.includes(n.publicationNumber)) continue; // already alerted\n seen.ids.push(n.publicationNumber);\n fresh.push({ json: n });\n}\nseen.ids = seen.ids.slice(-2000);\nreturn fresh;"
   },
   "name": "Only new open tenders",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    520,
    0
   ]
  },
  {
   "parameters": {
    "jsCode": "const rows = $input.all().map(i => i.json);\nif (!rows.length) return [{ json: { count: 0, text: 'No new tenders today.' } }];\nconst lines = rows.map(n => `• ${n.title}\\n ${n.buyer ?? ''} | deadline: ${n.deadline ? n.deadline.slice(0,10) : 'n/a'} | ${Number.isFinite(Number(n.estimatedValue)) && n.estimatedValue ? Math.round(Number(n.estimatedValue)).toLocaleString('en') + ' EUR' : 'value: see notice'}\\n ${n.url}`);\nreturn [{ json: { count: rows.length, subject: `${rows.length} new EU tenders for your keywords`, text: lines.join('\\n\\n') } }];"
   },
   "name": "Build digest",
   "type": "n8n-nodes-base.code",
   "typeVersion": 2,
   "position": [
    780,
    0
   ]
  }
 ],
 "connections": {
  "Every weekday 08:00": {
   "main": [
    [
     {
      "node": "Run TED Actor",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Run TED Actor": {
   "main": [
    [
     {
      "node": "Only new open tenders",
      "type": "main",
      "index": 0
     }
    ]
   ]
  },
  "Only new open tenders": {
   "main": [
    [
     {
      "node": "Build digest",
      "type": "main",
      "index": 0
     }
    ]
   ]
  }
 },
 "settings": {},
 "active": false
}

Enter fullscreen mode Exit fullscreen mode

Top comments (0)