# EU Public Tenders Scraper — TED + national bulletins (`baptsg/eu-public-tenders-scraper`) Actor

Scrape EU public tenders (appels d offres, marches publics, Ausschreibungen) from TED plus national bulletins like BOAMP, which carry below-threshold contracts TED never publishes. Lot-level CPV codes and deadlines from eForms. Deduplicated: ~28% appear twice, you pay once.

- **URL**: https://apify.com/baptsg/eu-public-tenders-scraper.md
- **Developed by:** [Baptiste Mairesse](https://apify.com/baptsg) (community)
- **Categories:** Business, Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $6.00 / 1,000 unique tender returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/platform/actors/running/actors-in-store#pay-per-event

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.
Actors are written with capital "A".

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## EU Public Tenders Scraper — TED + national bulletins

Public procurement notices from across the **EU and EEA** in one clean, deduplicated JSON feed — with **lot-level** CPV codes, deadlines and buyers extracted from the underlying eForms/UBL documents.

No API key. No login. No XML parsing on your side.

***

### Why this one

#### 1. Lot-level detail, because that is what you bid on

A tender is published as one notice, but it is awarded **lot by lot**. Each lot has its own CPV code, its own deadline and its own award criteria.

Every other procurement scraper consumes TED's *search API*, which returns one flat record per notice with no lots at all. This actor parses the **eForms/UBL document** behind the notice.

| | Search API *(what others use)* | eForms/UBL *(what this actor reads)* |
|---|---|---|
| Lots | **absent** | one entry per lot |
| CPV | one flat, duplicated array | **one code per lot** |
| Deadlines | one flat, duplicated array | **one deadline per lot** |
| Contract nature | absent | works / supplies / services |
| Buyer address | name only | city, postal code, NUTS region |

Real example from a live run: a French roadworks notice returned **29 lots**, each with its own CPV code. The search API returns that as a single row.

**Lots are included at no extra charge** — billing is per notice, never per row.

#### 2. Deduplicated across sources

Every contract above EU thresholds is published **twice**: once in its national bulletin, once on TED. Watch both and you read everything twice; watch one and you miss contracts.

Measured over a real 7-day window, all French tender notices from both sources:

```
BOAMP  1 656 notices
TED    1 338 notices
──────────────────────
collected            2 994
unique after dedup   2 166
duplicates removed     828   ← 27.7%
```

**Around 28% of notices are duplicates.** You are charged once per unique contract.

#### 3. Below-threshold contracts TED never carries

TED only publishes contracts above EU thresholds. National bulletins carry the rest — and by count, that is the majority. France is covered today through **BOAMP**; the same eForms parser applies to other national portals.

***

### Coverage

| Source | Scope |
|---|---|
| **TED** | All EU member states + EEA. Filter by country, or leave empty for everything. |
| **BOAMP** (France) | French national bulletin, including below-EU-threshold contracts. Added automatically when France is in scope. |

### Output

One record per contract, deduplicated:

```json
{
  "id": "boamp:26-72006",
  "title": "Travaux de construction et de réparation de branchements",
  "noticeType": "tender",
  "country": "FRA",
  "buyer": {
    "name": "MINISTERE DE LA CULTURE",
    "city": "Paris",
    "postalCode": "75001",
    "nutsCode": "FR101"
  },
  "cpv": {
    "main": "45232410",
    "all": ["45232410", "45231300"],
    "label": "Travaux de construction"
  },
  "contractNature": "works",
  "procedureType": "OUVERT",
  "departments": ["75"],
  "deadline": "2026-08-28T13:00:00.000Z",
  "publishedAt": "2026-07-17T00:00:00.000Z",
  "lots": [
    { "id": "LOT-0001", "title": "Branchements secteur nord", "cpv": "45232410", "deadline": "2026-08-28T13:00:00.000Z" }
  ],
  "isMultiSource": true,
  "urls": {
    "boamp": "https://www.boamp.fr/pages/avis/?q=idweb:26-72006",
    "ted": "https://ted.europa.eu/fr/notice/502512-2026"
  }
}
```

`isMultiSource: true` means the contract was found in both a national bulletin and TED, and the two records were merged into this one. `urls` links to the original notice on each platform.

All dates are **ISO 8601 in UTC**, so they are directly comparable. The sources disagree — one publishes local time, the other UTC — and that is normalized here.

### Input

Everything is optional. Run it with no input to get the last 7 days of open tenders across the EU.

| Field | What it does |
|---|---|
| `countries` | `FRA`, `FR`, `France`, `DEU`, `Germany`… Leave empty for the whole EU + EEA. |
| `publishedSince` / `publishedUntil` | Date window. Defaults to the last 7 days. |
| `noticeTypes` | `tender` (open calls), `award` (who won), `correction`, … Defaults to `tender`. |
| `onlyOpenTenders` | Drop notices whose deadline has passed. On by default. |
| `cpvCodes` | CPV code or prefix. `45` = construction, `72` = IT, `85` = health, `90` = waste. |
| `departments` | French department codes: `75`, `13`, `974`. France only. |
| `keywords` | Free-text terms matched against the notice subject. |
| `maxItems` | Hard cap on notices returned — and therefore on what you are charged. |

#### Example: construction tenders in Germany and the Netherlands

```json
{ "countries": ["DEU", "NLD"], "cpvCodes": ["45"], "onlyOpenTenders": true }
```

#### Example: French IT contracts, national and EU sources combined

```json
{ "countries": ["FRA"], "cpvCodes": ["72", "48"], "departments": ["75", "92", "93"] }
```

#### Example: who won waste-management contracts last quarter

```json
{ "noticeTypes": ["award"], "cpvCodes": ["90"], "publishedSince": "2026-04-01", "onlyOpenTenders": false }
```

### Typical uses

- **Daily tender watch** — schedule a morning run filtered to your CPV codes and countries. Pipe it to Slack, email or your CRM.
- **Competitive intelligence** — pull `award` notices to see who wins what, for which buyer.
- **Market sizing** — count contracts by CPV, country or region over a period.
- **Lead generation** — a buyer with an upcoming deadline in your sector is a prospect with a known budget and timeline.

### How deduplication works

The sources share no common identifier, so notices are matched on **buyer + deadline (to the day) + main CPV code**, then confirmed by title similarity before merging.

Three rules keep it honest, because wrongly merging two contracts hides one from you — worse than showing a duplicate:

1. **Numbers veto.** "Rehabilitation of 94 dwellings" and "…of 63 dwellings" are different contracts despite near-identical wording. If both titles carry numbers and none match, they never merge.
2. **One notice per source per group.** Two distinct national notices are two distinct contracts. This stops a generic TED title from chaining unrelated notices together.
3. **Stricter matching without a deadline.** When one source omits the deadline, titles must be substantially closer before anything merges.

Merging is deliberately conservative: you may occasionally see a duplicate, but you will not silently lose a tender.

### Reliability

- Retries with exponential backoff on rate limits (TED throttles aggressively) and transient failures, honouring `Retry-After`.
- Requests are paced, not bursted.
- **If one source fails, the other still returns.** A TED outage does not cost you the national results — the run reports which source failed in `RUN_SUMMARY`.
- **No silent truncation.** If a window is too large to cover fully, the run says so instead of returning a partial result that looks complete.

### Data and privacy

All sources are official open-data publications of public procurement notices. This actor:

- reads only public, unauthenticated endpoints — no login, no paywall, no protected pages;
- returns **organisation data** (public buyers, awarded companies), not personal profiles;
- **stores nothing.** Results go to the dataset of the run you launched, and nowhere else. No mirror database, no cache, no resale.

Sources: [TED — Tenders Electronic Daily](https://ted.europa.eu/) · [BOAMP open data (DILA)](https://boamp-datadila.opendatasoft.com/)

### Development

```bash
npm install
npm test                               # 113 tests, real frozen fixtures
npm run fixtures                       # refresh fixtures from live APIs
node scripts/verify-live.js 300        # end-to-end run against live sources
node scripts/measure-overlap.js 10 7   # measure real cross-source overlap
```

Tests run against fixtures captured from the live APIs and frozen. The sources change; the normalization logic must not regress silently. The output schema is validated against real output in both directions — no undeclared field, no declared field the code never produces.

# Actor input Schema

## `publishedSince` (type: `string`):

Only return notices published on or after this date (YYYY-MM-DD). Defaults to the last 7 days — the useful window for a daily watch.

## `publishedUntil` (type: `string`):

Only return notices published on or before this date (YYYY-MM-DD). Leave empty for 'up to now'. Use it with 'Published since' to analyse a past period.

## `countries` (type: `array`):

Which countries to cover. Leave empty for the whole EU + EEA — that is TED's natural scope. Accepts ISO codes or common names: FRA, FR, France, DEU, Germany… France additionally pulls BOAMP, the national bulletin, which carries below-EU-threshold contracts that TED never publishes.

## `noticeTypes` (type: `array`):

Which kinds of notices to return. 'tender' = open calls you can still bid on. 'award' = who won, and for how much.

## `onlyOpenTenders` (type: `boolean`):

Keep only notices you can still respond to. Turn off to include expired ones for market analysis.

## `cpvCodes` (type: `array`):

Filter by CPV code or prefix. Use a division to catch a whole sector: 45 = construction works, 72 = IT services, 85 = health and social care, 90 = waste and environment.

## `departments` (type: `array`):

Two- or three-digit department codes, e.g. 75, 13, 974. France only — applied to both sources, TED via its NUTS region codes.

## `keywords` (type: `array`):

Free-text terms matched against the notice subject. Any match is enough.

## `sources` (type: `array`):

BOAMP is the French national bulletin; TED is the EU journal. Above EU thresholds a contract appears in BOTH — keeping both and letting the actor deduplicate gives the widest coverage without paying twice for the same notice.

## `maxItems` (type: `integer`):

Hard cap on the number of deduplicated notices returned — and therefore on what you are charged.

## `enrichLots` (type: `boolean`):

Parse the full eForms/UBL document behind each TED notice to extract its lots, each with its own CPV code and deadline. This is what bidders actually respond to, and TED’s search API does not expose it. Adds about one request per returned notice; turn it off for the fastest possible run.

## Actor input object example

```json
{
  "noticeTypes": [
    "tender"
  ],
  "onlyOpenTenders": true,
  "sources": [
    "boamp",
    "ted"
  ],
  "maxItems": 500,
  "enrichLots": true
}
```

# Actor output Schema

## `tenders` (type: `string`):

All notices returned by this run, one item per unique contract. Notices published in both a national bulletin and TED are already merged, so no contract appears twice.

## `lots` (type: `string`):

The same notices seen through their lots, each with its own CPV code and deadline. A tender is awarded lot by lot, so this is the view to use when matching opportunities to what you actually bid on. Extracted from the eForms/UBL document behind each notice.

## `awards` (type: `string`):

Awarded contracts and their winning companies. Only populated when "award" is included in the noticeTypes input.

## `runSummary` (type: `string`):

How many notices were collected, how many survived deduplication, how many duplicates were merged across sources, how many were charged, and whether any source failed or was only partially covered. Read this to know whether the run gave you complete coverage.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {};

// Run the Actor and wait for it to finish
const run = await client.actor("baptsg/eu-public-tenders-scraper").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {}

# Run the Actor and wait for it to finish
run = client.actor("baptsg/eu-public-tenders-scraper").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print("💾 Check your data here: https://console.apify.com/storage/datasets/" + run["defaultDatasetId"])
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{}' |
apify call baptsg/eu-public-tenders-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=baptsg/eu-public-tenders-scraper",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/9I8ZhlnSIfc3bxAYH/builds/cycTpQLvxgrmL4x6Y/openapi.json
