# ted-tender-change-monitor (`datadelta/ted-tender-change-monitor`) Actor

Monitor TED public procurement notices, normalize tender data, and detect NEW, UPDATED, and CORRIGENDUM changes.

- **URL**: https://apify.com/datadelta/ted-tender-change-monitor.md
- **Developed by:** [DataDelta](https://apify.com/datadelta) (community)
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $2.00 / 1,000 results

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/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
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.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## 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.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## TED Tender Change Monitor

Monitors official EU **TED** (Tenders Electronic Daily) procurement notices and reports **what changed** since the previous run.

Instead of re-emitting a raw tender feed, this Actor answers one question: *which notices are new, which were updated, and which are corrections?*

> **Source:** TED Search API v3 — `POST https://api.ted.europa.eu/v3/notices/search`. The API is official, public and keyless. No credential, account or proxy is used.

***

### What it does

1. Queries TED for a bounded publication-date window.
2. Projects a **fixed set of organisation-level fields**.
3. Computes a deterministic SHA-256 fingerprint per notice.
4. Compares against a stored snapshot from earlier runs.
5. Emits one dataset row per changed notice, tagged `NEW`, `UPDATED` or `CORRIGENDUM`.

### Output

One row per emitted notice:

| Field | Meaning |
|---|---|
| `deltaStatus` | `NEW`, `UPDATED`, `CORRIGENDUM` or `UNCHANGED` |
| `publicationNumber` | TED publication number (stable notice identifier) |
| `publicationDate` | Publication date |
| `noticeType` | TED notice type |
| `formType` | TED form type (corrections are detected from this) |
| `procedureType` | Procedure type |
| `buyerName` | Contracting authority (organisation-level) |
| `buyerCountry` | Buyer country code |
| `legalBasis` | Legal basis |
| `noticeIdentifier` | Notice identifier |
| `tedNoticeUrl` | Link to the notice on TED |
| `contentFingerprint` | SHA-256 over the sorted organisation-level fields, so the change decision can be verified independently |
| `firstSeenAt` / `lastSeenAt` | When the notice was first and most recently observed |
| `changeDetectedAt` | When the current state was detected |
| `sourceAttribution` | Required attribution for reused TED content |

The auto-attached TED `links` block is deliberately dropped: it is not stable, so including it would produce false `UPDATED` results.

### Change semantics

Precedence is fixed and deterministic:

| Situation | State |
|---|---|
| Never seen before, and it is a correction | `CORRIGENDUM` |
| Never seen before, otherwise | `NEW` |
| Seen before and content changed, and it is a correction | `CORRIGENDUM` |
| Seen before and content changed, otherwise | `UPDATED` |
| Seen before and content identical | `UNCHANGED` |

A correction takes precedence over `NEW`/`UPDATED` because that is the more useful fact about it.

### Input

**Every input is optional.** With no input the Actor monitors the **last 7 days**, which keeps a default run small and cheap.

| Input | Default | Notes |
|---|---|---|
| `dateFrom` | 7 days before `dateTo` | `YYYY-MM-DD` |
| `dateTo` | today (UTC) | `YYYY-MM-DD` |
| `countries` | all | Buyer country codes, e.g. `DEU`, `FRA`, `LTU` |
| `noticeTypes` | all | TED notice-type codes |
| `includeUnchanged` | `false` | See billing below |
| `maxNotices` | `200` | Bounds runtime and platform cost (hard ceiling 5000) |
| `snapshotKey` | `default` | Namespace for the stored snapshot, to keep independent pipelines apart |
| `resetSnapshot` | `false` | Discards the snapshot first, so everything is reported again |

The maximum publication-date window is 366 days.

### Billing

**You are charged per dataset item, and this Actor writes exactly one item per emitted notice.**

With `includeUnchanged: false` (the default) only `NEW`, `UPDATED` and `CORRIGENDUM` notices are written, so **you are charged only for results that carry information**. An unchanged re-run produces no rows and no charge.

This Actor defines **no custom charge events** and never calls `Actor.charge()`. Monetisation uses only the platform's synthetic per-item event.

> `resetSnapshot: true` deliberately makes previously reported notices billable again. Use it only when you intend to re-read a window from scratch.

### Personal data

Only organisation-level fields are requested. The field list is an **allowlist, and it is not user-configurable**: because contact fields are never requested, TED never returns them and they cannot reach the output. The TED field enum does contain contact fields (for example `organisation-email-tenderer`, `touchpoint-fax-buyer`, `ubo-email`); none of them are used, and a start-up guard refuses to run if a personal-data field name is ever added to the configuration.

As a second line of defence, every projected value is scanned before output and any email address — or phone-shaped value in a field where digit runs are not legitimate — is redacted and reported in the run summary. Fields such as `legalBasis` are exempt from phone-shaped matching so that a value like `32014L0024` is preserved.

### Resilience

TED explicitly **excludes the field list from its API versioning**, so a field name can be added, removed or renamed inside v3 without a version change. This Actor therefore validates its field list against the live API at run time and **fails soft**: if a field is rejected, the offending name is identified, dropped, and the run continues with the remaining fields. Country and notice-type filters are always re-applied locally, so results stay correct even when the query has to be degraded to a plain date range.

Any degradation is recorded in the run's `SUMMARY` record rather than being silently ignored.

### Attribution

TED procurement notices may be reused commercially. Editorial content is licensed **CC BY 4.0**, which requires credit and an indication of changes — `deltaStatus` is that indication, and `sourceAttribution` carries the credit on every row.

### Development

The rules live in dependency-free modules (`src/normalize.py`, `src/ted_client.py`, `src/snapshot.py`, `src/pipeline.py`, `src/runparams.py`, `src/filtering.py`). Only `src/main.py` and `src/http_transport.py` touch the Apify SDK and HTTP.

```bash
python -m pytest tests -q
```

The test suite runs fully **offline**: the HTTP transport is injected, so no network access or credential is required.

### Known limitations

- **Country and notice-type query predicates are not verified** against the live TED API. They are attempted first and degraded away on HTTP 400; the filter is then applied locally, which is correct but may fetch more notices than strictly necessary.
- The format of TED's `buyer-country` **values** is not verified, so an unrecognised code yields no matches rather than an error.
- Corrigendum detection relies on returned `form-type`/`notice-type` values, not on a TED query filter: the documented `form-type=corrigendum` filter returned HTTP 400 when last tested.
- `UNCHANGED` notices are still recorded in the snapshot so they cannot age out and be re-billed as `NEW`. The snapshot is pruned after 180 days.
- The Actor reports a **failed run** if TED returns no notices *and* reported errors, rather than reporting a clean empty run. A silent "success" would be indistinguishable from "nothing matched" and would hide a broken integration.

# Actor input Schema

## `dateFrom` (type: `string`):

Start of the TED publication-date window, format YYYY-MM-DD. Defaults to 7 days before dateTo.

## `dateTo` (type: `string`):

End of the TED publication-date window, format YYYY-MM-DD. Defaults to today (UTC).

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

Optional ISO-3 buyer country codes, e.g. DEU, FRA, LTU. Leave empty for all countries. Applied as a TED query filter when supported and always re-checked locally.

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

Optional TED notice-type codes, e.g. can-standard, corr. Leave empty for all types. Applied as a TED query filter when supported and always re-checked locally.

## `includeUnchanged` (type: `boolean`):

When false (default), only NEW, UPDATED and CORRIGENDUM notices are written to the dataset, so you are charged only for results that carry information. When true, every notice in the window is written, including UNCHANGED ones.

## `maxNotices` (type: `integer`):

Upper bound on how many notices this run will scan. Bounds runtime and platform cost. Default 200.

## `snapshotKey` (type: `string`):

Name of the stored snapshot used to compute changes. Use different names to keep independent pipelines (for example one per country group).

## `resetSnapshot` (type: `boolean`):

When true, the stored snapshot for this namespace is discarded first, so every notice in the window is reported as NEW or CORRIGENDUM. Use with care: it makes previously seen notices billable again.

## Actor input object example

```json
{
  "includeUnchanged": false,
  "maxNotices": 200,
  "snapshotKey": "default",
  "resetSnapshot": false
}
```

# Actor output Schema

## `results` (type: `string`):

Default dataset containing one item per emitted notice.

# 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("datadelta/ted-tender-change-monitor").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("datadelta/ted-tender-change-monitor").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).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 datadelta/ted-tender-change-monitor --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,datadelta/ted-tender-change-monitor"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/MsZbW4a7tt9Tf0tyE/builds/nNrZJHlWteiEl2B8Y/openapi.json
