# Tender Opportunity Intelligence (`atlas-data/tender-opportunity-intelligence`) Actor

Normalize, deduplicate, rank, and track EU procurement opportunities from TED.

- **URL**: https://apify.com/atlas-data/tender-opportunity-intelligence.md
- **Developed by:** [Atlas](https://apify.com/atlas-data) (community)
- **Categories:** Business
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

This Actor is paid per platform usage. The Actor is free to use, and you only pay for the Apify platform usage, which gets cheaper the higher subscription plan you have.

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

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

## Tender Opportunity Intelligence

Turn the EU's Tenders Electronic Daily (TED) feed into a ranked, normalized opportunity stream your sales, bid, and market-intelligence workflows can use directly.

This Actor is more than an API relay. It traverses TED's unbounded `ITERATION` mode, normalizes multilingual and multi-lot fields, applies safe business filters, explains every relevance score, deduplicates notices, detects edits, and keeps each search profile's cursor and digest history isolated.

### Who it is for

Use it if you run EU public-sector pipeline discovery for a bid team, sales operation, specialist supplier, procurement consultancy, or market-intelligence product. It is especially useful when people currently search TED by hand, maintain brittle exports, or receive broad alerts that still require hours of triage.

The reason to pay is the workflow around the public data: bounded exhaustive traversal, stable normalized records, auditable ranking, change detection, and replay-safe incremental state. Those are the parts a team would otherwise have to build, operate, and monitor around the TED API before the feed is dependable enough for a CRM, warehouse, alerting flow, or analyst queue.

### What you get

Each dataset row uses dataset `schemaVersion` **1.1.0** and includes:

- `emissionId`, a deterministic identity for the emission itself — see [Deduplication and upserts](#deduplication-and-upserts);
- stable TED notice ID, version, canonical URL, preferred-language title, and buyer details;
- buyer countries and place-of-performance countries as distinct fields, raw performance places, normalized CPV codes, and canonical calendar dates;
- every procedure/lot estimate as an amount-currency pair; a convenience largest value appears only when all estimates are safely comparable;
- notice type, procedure type/identifier, contract nature, and legal basis;
- a numeric relevance score plus the exact positive and negative signals that produced it;
- `changeType` (`snapshot`, `new`, `updated`, or `unchanged`) and top-level `changedFields`;
- `fetchedAt` and explicit TED attribution.

### Run modes

`full` emits every match with `changeType: "snapshot"`.

`incremental` emits only new and updated matches. It overlaps the previous completed publication watermark (three days by default) to catch edits to recently published notices.

`diff` performs the same durable tracking but also emits unchanged matches, which is useful for reconciliation and warehouse snapshots.

State is keyed by a SHA-256 fingerprint of all selection and relevance settings. Changing a country, keyword, date bound, value currency, language, or scoring profile cannot accidentally consume another configuration's cursor. Operational caps such as `maxItems` do not fork business state. Full-snapshot and change-tracking resumes are separate, so `full` never consumes or advances an incremental cursor.

### Example input

```json
{
  "mode": "incremental",
  "keywords": ["cybersecurity", "zero trust", "SOC"],
  "cpvPrefixes": ["72", "4873"],
  "buyerCountries": ["DEU", "NLD", "BEL"],
  "publicationFrom": "2026-01-01",
  "deadlineFrom": "2026-08-01",
  "minimumValue": 250000,
  "minimumValueCurrency": "EUR",
  "noticeTypes": ["cn-standard"],
  "language": "eng",
  "maxItems": 500,
  "relevance": {
    "include": [
      { "term": "managed service", "fields": ["title"], "weight": 25 },
      { "term": "72000000", "fields": ["cpv"], "weight": 12 }
    ],
    "exclude": [
      { "term": "hardware only", "fields": ["title"], "weight": 30 }
    ],
    "minimumScore": 8,
    "requireIncludeMatch": false
  }
}
```

Keywords use OR semantics across title and buyer name. CPV prefixes use OR semantics. Different filter groups combine with AND. Deadline bounds require **one single deadline** to satisfy every supplied bound: with both `deadlineFrom` and `deadlineTo` set, a notice is kept only when one of its deadlines falls inside that interval, never because separate deadlines sit on opposite sides of it. Notice type codes are lowercased before comparison, so `cn-standard` and `CN-STANDARD` select the same notices and share one configuration fingerprint. `minimumValue` and `minimumValueCurrency` must be supplied together; the filter succeeds only when an estimate in that exact currency reaches the threshold. No different/unknown currencies are compared and no FX conversion is attempted.

### Relevance model

The built-in explainable score adds 10 points per matched keyword and 8 per matched CPV prefix. `relevance.include` rules add their configured weight for each matching field; `exclude` rules subtract it. Set `minimumScore` to reject weak opportunities, and `requireIncludeMatch` when at least one positive profile rule must match.

Rules accept only these normalized fields: `title`, `buyer`, `cpv`, `country`, `place`, and `procedure`. `country` means buyer country; `place` covers performance-country codes and raw places. Matching is case-insensitive substring matching after Unicode NFC normalization. Every contribution appears in `relevance.signals`, so downstream users can audit or tune the ranking.

### Safety, traversal, and recovery

The Actor never inserts user text into TED expert-query syntax. The only server-side query is a generated publication-date window made from strictly validated, real-calendar `YYYY-MM-DD` values; keywords, codes, countries, values, deadlines, notice types, and ranking are client-side filters. TED currently returns publication dates with a numeric UTC offset and deadlines as timestamps. The Actor accepts only those documented forms, validates the calendar date, rejects arbitrary suffixes/impossible dates, and emits canonical `YYYY-MM-DD`.

TED's official API permits at most 250 notices per page and 10,000 requested fields per page. The Actor requests 20 fields, validates every envelope, row identity, count, iteration token, and timeout flag when present. The official OpenAPI does not require `timedOut`, so absence is accepted; `timedOut: true` still fails closed. Requests run sequentially at a conservative minimum 150 ms interval. HTTP 429, 5xx, and network failures receive at most four exponential-backoff retries. Valid `Retry-After` delta-seconds and future HTTP-date values are honored up to 30 seconds; missing, malformed, or past values use exponential backoff.

`maxItems` counts matched opportunities—not raw rows—so a match behind a sparse backlog of rejected notices is still found. `maxPages` and `maxScannedItems` bound cost. When a cap lands inside a page, state records the exact query window, run mode, original page size, inbound token, row offset, and compact source ledger. Later runs finish that exact window even on the next day and preserve the saved page contract when caps or page size change. A change between `incremental` and `diff` invalidates the token and replays the saved window from its start; the ledger prevents already-handled rows from hiding an unprocessed row. Expired/invalid TED tokens use the same replay path.

The Actor writes dataset rows first, then the `OUTPUT` summary as `status: "pending"` / `phase: "pre-commit"`, and commits cursor/digest state **last**. A source, dataset, summary, lease, or state-write failure before that state commit therefore leaves work replayable, and the summary that survives such a failure never claims durable success.

**The state commit is the last operation that can fail a run.** Everything after it — rewriting `OUTPUT` as `succeeded` or `partial` with `phase: "committed"`, and releasing the writer lock — is cosmetic and strictly best-effort: each step is contained, so neither can reject and the run always resolves. A run whose state is durable therefore never reports failure, which would otherwise invite an operator to replay work that the committed state already suppresses. If the rewrite fails, the conservative `pending` record survives and understates a committed run rather than overstating it.

Replays can duplicate already-written dataset rows, which is why every row carries `emissionId`. Conflicting rows with the same TED notice ID and version fail across pages and partial runs. Once a window completes, later content/version changes are handled normally as updates.

### Deduplication and upserts

**Upsert dataset rows on `emissionId`.** It is a SHA-256 identity derived only from durable inputs: the configuration fingerprint, the TED notice ID and version, the classified `changeType`, the content digest, and — for `updated` rows only — the digest it replaced plus the notice's durable transition generation. No run timestamp, run ID, or dataset offset takes part.

The transition generation is a per-notice counter in durable state that advances by one for each **committed** `updated` transition. Content digests alone cannot separate a repeated edit cycle: a title that goes A → B → A → B produces the identical `(digest, previousDigest)` pair twice, so the second edit would collapse onto the first. The counter separates them while keeping replay exact, because it is derived from the last committed entry: a run whose state write failed and the run that replays it read the same committed generation and therefore emit the same ID.

That gives consumers two guarantees:

- **Replay collapses.** If a run's dataset rows and `OUTPUT` were written but its state commit failed, the next run re-emits the same logical rows with identical `emissionId` values even though `fetchedAt` differs. Upserting on `emissionId` and keeping the newest `fetchedAt` converges to one row per emission, with no duplicates and no lost edits.
- **Real change does not collapse.** New content, a new notice version, a different change event, or a different search configuration all produce a different `emissionId`. A notice that is edited and later reverted yields three distinct IDs, because the revert is a distinct event rather than a repeat of the first sighting — and a notice that keeps flipping between two texts yields one ID per committed edit, never a repeat.

Two deliberate consequences follow. In `diff` mode, re-observing settled content produces `changeType: "unchanged"` with the same `emissionId` every run, so a daily reconciliation feed upserts in place instead of growing; use `fetchedAt` for last-seen tracking. And because `emissionId` includes the configuration fingerprint, the same notice discovered by two different search profiles is two rows — merge them on `noticeId` if that is what you want.

Treat `fetchedAt` as observation metadata only. Never key on it, and never key on `noticeId` alone unless you intend to keep only the newest state of each notice and discard its change history.

TED fetching and writes are sequential within each run. Cross-run state lives in the stable named key-value store `tender-opportunity-intelligence-state-v1`, not the run-exclusive default store. State commits are immutable, uniquely keyed snapshots. Their generation comes from the Request Queue service's lock-expiry timestamp. After acquiring the queue lock, a run loads every visible candidate and deterministically chooses the greatest document revision tuple: snapshot generation first, then maximum configuration `updatedAt`, then sorted configuration revision content, with the full snapshot key used only when those semantic fields are identical. A lower-generation stale writer therefore cannot outrank a committed higher-generation writer even if its application clock is ahead, and a stale snapshot cannot win an equal-generation comparison solely because its random UUID sorts later. Empty snapshot listings exhaust all three bounded attempts before the Actor reads the legacy `TENDER_OPPORTUNITY_STATE_V1` compatibility record; the first successful new commit migrates future visible reads to snapshots without overwriting that legacy record.

A single persistent request in the named Request Queue `tender-opportunity-intelligence-lock-v1` provides exclusive writer leases. This queue name is separate from the pre-existing state store and must be creatable on the first upgraded run. Each run acquires the request lock and prolongs it during traversal and around the final state write. Queue lock operations use the queue client's ownership key, so a stale release cannot unlock a later client. Release is best-effort after a successful durable commit: a release 5xx does not mark the committed run failed, and the lock remains unavailable until its server-side lease expires.

Under pay-per-event pricing a second named Request Queue, `tender-opportunity-intelligence-billing-attempts-v1`, holds one permanent request per charge attempt. Each request's unique key is `qualified-opportunity:r1:<runId>:<emissionId>`, so an attempt belongs to the Actor run that made it: a retry, restart, or migration reboot of that run re-derives the same key and is suppressed, while a later run has its own namespace and bills normally. The queue is deliberately separate from the lock queue, because mixing the two would change lock head selection and lease retention. Its requests are synthetic and never fetched, it is only ever appended to, and it is never created at all under any other pricing model. Do not delete it: it is what stops a run from charging the same emission twice, and it remains the audit trail after a rollback. Unscoped `qualified-opportunity:<emissionId>` keys written before charge attempts were run-scoped stay in the same queue and cannot collide with the scoped format.

When the platform raises `aborting` or `migrating`, the Actor's listener sets a synchronous stop flag and returns a promise that stays pending until the run has written `OUTPUT` and committed state. The Apify SDK awaits event listeners before it finishes `Actor.exit()` and before `Actor.reboot()` asks the platform to kill the container, so this is what actually holds the shutdown open; the listener itself never writes state or charges anything, and it resolves on the failure path too. The hold is capped at 25 seconds, below the SDK's own 30-second exit timeout, so a stuck commit degrades to the hard-kill case instead of waiting for the platform to kill the container.

The Request Queue provides lock acquisition and lease-expiry semantics; separate dataset, `OUTPUT`, and state writes are not one transaction. A lock can expire during a slow external operation. The old writer's next ownership check fails, while immutable snapshots and document revision ordering prevent its delayed state from replacing the later owner's visible state. Already-written dataset rows or `OUTPUT` remain replayable. Keep schedules non-overlapping to avoid duplicate dataset rows and rejected overlapping runs.

Snapshot loads are bounded because the key-value store can be eventually consistent: if listed records disappear during reads, the Actor tries every listed candidate and refreshes the listing before selecting the newest document it actually loaded. Because the runner already holds the queue lock, no conforming writer can commit concurrently during this load. The remaining failure case is storage visibility lag: a durable snapshot omitted from every bounded listing cannot be selected, so the Actor can choose the newest visible snapshot or, after three empty listings, the legacy record. If listings advertise snapshots but none can be loaded during the bounded retries, the run fails closed instead of silently falling back.

Snapshot cleanup targets at most the newest 32 visible records and only deletes excess keys whose parsed generation is strictly less than the current writer's generation. It never deletes a same-generation, concurrent, or newer snapshot. Cleanup is best-effort, so failed cleanup, stale writers, or preservation of unsafe-to-delete generations can leave more than 32 records; the legacy compatibility record is not deleted.

### Bounded state

Each configuration retains compact overall/change-field digests for 180 days and at most 5,000 notices by default. Tune `stateRetentionDays` (7–730) and `maxStateEntries` (100–12,000). Up to eight recent configurations are normally retained; partial-resume configurations are protected even if that temporarily exceeds eight. Before every write, the entire UTF-8 JSON document is measured against a conservative 3,500,000-byte budget; completed old configurations and then oldest notice digests are pruned. Partial resume windows/source ledgers are never silently discarded. If protected resume data cannot fit, the run fails closed before writing the summary or state. An opportunity/configuration forgotten by retention is classified as `new` if encountered again.

The internal state document carries its own version, independent of both published `schemaVersion` values, and the digest and configuration hash domains are pinned to the first published contract. Dataset rows stay on 1.1.0 and the `OUTPUT` summary moved to 1.2.0 purely additively, so both are invisible to durable state: digests recorded by earlier releases stay valid, and no run reports a wave of phantom updates after the upgrade.

Each notice digest also carries its transition generation as an optional field that is omitted while it is zero, so entries written before the field existed load unchanged and count as generation 0. The counter wraps below one billion, keeping it at most nine digits and unable to grow the byte budget; reaching a wrap would take a billion committed edits to one notice. A notice dropped by retention returns as `new` at generation 0, which is the same bounded-state trade-off that reclassification already makes.

### What a qualified opportunity is

When this Actor runs under pay-per-event pricing, the billable unit is one **qualified opportunity**: a `qualified-opportunity` event stands for exactly one dataset row that

1. was validly normalized from a TED notice,
2. passed every business filter,
3. scored at least `relevance.minimumScore`,
4. produced an include signal when `requireIncludeMatch` is on,
5. is emitted by the run mode, and
6. **was already stored in the dataset** with a valid `emissionId`.

No other work of this Actor's is billable. Filtered, low-scoring, duplicate, and malformed notices carry no event, and neither does a run that finds nothing.

One charge is not this Actor's to make. Apify's synthetic `apify-actor-start` event is charged by the platform once per run whenever the pricing configuration prices it, before any code of ours runs and regardless of what the run finds. Read "free" below as *no `qualified-opportunity` charge*: a zero-result or five-result run still pays the listed start fee plus compute.

| Mode | Change type | Dataset row | Charged after the free results |
| --- | --- | --- | --- |
| `full` | `snapshot` | yes | yes |
| `incremental` | `new` / `updated` | yes | yes |
| `incremental` | `unchanged` | no | no |
| `diff` | `new` / `updated` / `unchanged` | yes | yes |
| any | filter or score reject | no | no |
| any | duplicate source row | no | no |
| any | malformed row or source failure | no | no |

#### The first five qualified results carry no event charge, every run

`freeLimit` is **5** and is a code constant, not an input — nothing you pass can raise or lower it. Every run stores its first five qualified rows without a `qualified-opportunity` event, so a copy-paste profile can be evaluated for the price of a run start and its compute. A run that is migrated or restarted seeds this counter from the rows it already stored rather than granting five more.

The `apify-default-dataset-item` event must stay absent from the pricing configuration. If it ever carries a nonzero price, the platform would bill every stored row automatically — including the free proof — and double-charge every qualified row. The Actor refuses to start in that case, and it also refuses to start under pay-per-event pricing without a priced `qualified-opportunity` event. Both failures happen before the first TED request, so a misconfiguration never bills you for work.

Under any other pricing model the Actor stores every row for free, creates no billing records at all, and reports `billing.mode: "inactive"`. In that mode the five-result allowance is not applied at all: every stored row is counted in `billing.freeStored`, and `paidStored`, `charged`, `replayedWithoutCharge` and `undercharged` all stay at zero.

#### What a charge can and cannot do

Ordering is fixed: budget check, then the dataset row, then a durable charge-attempt marker, then exactly one single-unit charge. A row is therefore always readable before it can be billed, and there is no batching.

- **Spend limit reached before a row.** The row is not stored, the run stops with `stopReason: "maxTotalCharge"` and `status: "partial"`, and the saved cursor points back at that exact row so a later run with budget picks it up.
- **Spend limit reached by the last charged row.** That row was validly paid for; the run stops with `maxTotalCharge` and resumes past it.
- **A row could not be charged.** The row stays available and free. The run stops with `stopReason: "chargeError"`, reports `billing.undercharged`, and does not start more paid work — a local problem and a platform billing incident must not look alike.
- **Graceful abort or migration.** The platform raises `aborting` or `migrating`, the run stops at the next row boundary with `stopReason: "gracefulAbort"`, and the Actor holds the container's shutdown or migration reboot open until `OUTPUT` and the resume cursor are committed. Work in progress is never charged. The hold has a 25-second deadline, after which the container is released even if the commit has not landed, because an unbounded hold would just be hard-killed by the platform instead.
- **A hard abort.** A hard kill, an out-of-memory kill, or a container failure raises no event at all, so no handler runs, no cursor commits, and there may be no `OUTPUT`. Whatever the dataset, the marker queue, and the charge ledger had already accepted stays accepted, and the run's next attempt picks up from that residue under the rule below.
- **A retried, restarted, or migrated run.** The attempt marker is keyed by `emissionId` **within one Actor run**, and `emissionId` is a pure function of durable inputs. A run that is retried, restarted, or rebooted for migration keeps its run ID, so it re-derives identical marker keys: if its rows and summary landed but its cursor did not, the replay re-emits the same rows, hits its own markers, and is **not** charged again. You may see the duplicate row; upsert on `emissionId`.
- **A later run over the same notices.** A new run gets a new run ID and a fresh marker namespace, so it bills the qualified rows it stores exactly like any other run. This is deliberate: a recurring `full` snapshot and a settled `diff` row are re-delivered every run, and the per-run pricing above is what pays for that delivery. Reruns are not a way to get the same rows for free.

The guarantee is *at most one charge attempt per logical emission per Actor run*, never a charge for a row you cannot read. Crossing that boundary would be an overcharge; billing the same stable emission in a genuinely later run is the pricing model, not a duplicate charge.

Dataset, marker, charge, and state are four separate systems, so an abort at the wrong instant resolves toward an **undercharge** and never an overcharge — the marker is written before the charge, so an interrupted attempt suppresses itself for the rest of that run. Where that undercharge is visible depends on how the run ended: a graceful stop reports it in `billing.undercharged` and `billing.errorClass`, while a hard kill can land between the dataset row, the marker, the charge, and the summary write, in which case there is no `OUTPUT` for this run to report it in. The Apify run's `chargedEventCounts` and its dataset row count remain the authoritative record in that case, and the bias is still toward charging less than was delivered.

### Run output

The `OUTPUT` key uses `schemaVersion` **1.2.0** and contains a summary with counts for scanned, matched, emitted, duplicates, and each change type; the stop reason; the billing record; configuration hash; query window; dataset ID; timestamp; and attribution.

`billing` reports what the run actually cost:

```json
{
  "mode": "pay-per-event",
  "eventName": "qualified-opportunity",
  "freeLimit": 5,
  "freeStored": 5,
  "paidStored": 20,
  "charged": 20,
  "replayedWithoutCharge": 0,
  "undercharged": 0,
  "limitReached": false
}
```

`freeStored` and `paidStored` count rows this run stored; `charged` counts `qualified-opportunity` events it actually billed, and never includes the platform's `apify-actor-start` event. They are independent of `matched`, because a matched notice is not billable until its row is durable. `replayedWithoutCharge` counts rows this run re-emitted after an earlier attempt of the *same* run had already made their one charge attempt. `undercharged` is stored-but-unbilled work in your favour, and `errorClass` names the reason with a stable enum (`MarkerWriteError`, `ChargeApiError`, `ChargeRejected`) rather than a raw platform message.

`status` and `phase` state exactly how durable the run is:

| `status` | `phase` | Meaning |
| --- | --- | --- |
| `pending` | `pre-commit` | Dataset rows and this summary are written; cursor/digest state is not — unless the run itself succeeded, in which case state is durable and only the cosmetic rewrite failed. A replay is safe either way. |
| `succeeded` | `committed` | The query window was exhausted and state is durable. |
| `partial` | `committed` | An operational cap, a spend limit, a charge failure, or an abort stopped the run; state is durable and carries a safe resume cursor. |

`stopReason` is one of `exhausted`, `maxItems`, `maxPages`, `maxScannedItems`, `maxTotalCharge`, `chargeError`, or `gracefulAbort`. Only `exhausted` reports `succeeded`.

A `pending` summary left behind by a failed run is the accurate record of a replayable run, not a failure to report. Read durability from the record together with the Apify run status: a `pending` summary on a **failed** run means the emissions will be replayed with identical `emissionId` values, while a `pending` summary on a **succeeded** run means state committed and the best-effort rewrite did not land, so a rerun simply finds nothing new. Automation that wants the strongest signal should require a successful run with `phase: "committed"`; treating the rare succeeded-but-`pending` run as replayable is safe, because upserting on `emissionId` makes a replay idempotent.

### Cost expectations

If the Store listing shows a `qualified-opportunity` price, a run's event cost is that price times the qualified rows it stored **beyond the first five**, plus the listed `apify-actor-start` price once for the run itself. Let `P` be the listed per-opportunity price and `S` the listed start price:

| Run | `apify-actor-start` | Charged `qualified-opportunity` events | Event cost |
| --- | ---: | ---: | --- |
| 0 or 5 qualified results | 1 | 0 | `S` |
| 25 qualified results | 1 | 20 | `S + 20 × P` |
| 100 qualified results | 1 | 95 | `S + 95 × P` |

Every run pays `S`, including one that finds nothing. This Actor never charges `apify-actor-start` itself; the platform does, from the pricing configuration, before the code starts.

Read the current price from the Store listing rather than from this document; the listing is the only authoritative source, and this Actor deliberately hard-codes no price. Cap a run with the platform's maximum-total-charge setting: the Actor checks the remaining budget before storing each paid row, so it stops cleanly instead of overshooting. The compute figures below apply on top of any event charges.

The committed Actor default is **1024 MB**. At that allocation, one hour of runtime consumes 1 compute unit (CU), so a 10-minute run consumes about 0.167 CU. At the **$0.20/CU observed on the release account**, that is about **$0.033 of compute for 10 minutes** or **$0.20 for one hour**, before storage operations and data transfer. A one-minute small run is roughly $0.0033 of compute.

These are planning examples, not a quoted price. The actual CU rate and storage/data-transfer charges depend on the runner's Apify plan, discount tier, pricing model, chosen memory override, and run duration. TED response volume and your `maxPages`, `maxScannedItems`, and `maxItems` limits determine how long a run takes. Start with a narrow date window and inspect the run's Usage details before scheduling a large recurring search.

### Local development

Requires Node.js 22.

```bash
npm ci
npm run lint
npm run typecheck
npm run build
npm test
npm run smoke:local
npm run smoke:live
npm audit --omit=dev
npm audit
apify validate-schema
```

`npm run smoke:live` makes a one-item anonymous request to the live TED API. Regular tests, named-store adapter tests, and the local smoke use fixtures and never need network access, cloud storage, or secrets.

`test/local-ppe.test.ts` runs the real Apify charging manager with `ACTOR_TEST_PAY_PER_EVENT=true` in a throwaway storage directory. It proves against the SDK — not a stub — that the first five rows carry no event, that the charging log holds exactly one entry per later row, that every charged row has a durable attempt marker for its run scope, that a second run scope over the same marker queue charges again, and that a spend cap below, at, and above one event price stops at the right row.

`test/lifecycle-sdk.test.ts` drives the real SDK shutdown path: it emits `aborting` through the actual event manager and asserts `Actor.exit()` stays pending until `OUTPUT` and state are committed, then does the same for the listener await `Actor.reboot()` performs on migration. `test/hard-abort.test.ts` injects a kill at the dataset→marker, marker→charge, charge→OUTPUT, and OUTPUT→state boundaries and asserts that no emission is ever charged twice within one run, that no charge outlives its dataset row, and that a killed run never makes the notices permanently free.

Off the platform there is no Actor run ID to scope charge attempts by, so the deterministic scope `local` is used. Set `ACTOR_TEST_BILLING_RUN_SCOPE` to another `[A-Za-z0-9_-]` token to simulate a distinct run locally. It is read **only** when the Actor is not running on the platform; on the platform the scope is always the real run ID.

The production image compiles TypeScript in an isolated build stage and installs runtime dependencies in the final Node.js 22 image. No API key is required for anonymous TED notice search.

### Data source and reuse

Source: [TED — Tenders Electronic Daily](https://ted.europa.eu/) and the anonymous [TED Search API](https://api.ted.europa.eu/v3/notices/search).

TED data may be reused commercially under the TED legal notice. TED editorial documentation is licensed under CC BY 4.0 and metadata is CC0. Preserve the attribution emitted with every row and review the current [TED legal notice](https://ted.europa.eu/en/legal-notice) for your use case. This Actor does not claim ownership of TED source data and does not provide legal or procurement advice.

# Actor input Schema

## `mode` (type: `string`):

full emits every match as snapshot; incremental emits only new/updated matches; diff also emits unchanged matches.

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

A notice matches when at least one keyword occurs in its normalized title or buyer name.

## `cpvPrefixes` (type: `array`):

Two to eight digits. Prefix matching lets 72 match the whole IT-services family.

## `buyerCountries` (type: `array`):

TED/ISO alpha-3 country codes, for example DEU, FRA, or CZE.

## `placeCountries` (type: `array`):

TED/ISO alpha-3 country codes, for example DEU, FRA, or CZE.

## `publicationFrom` (type: `string`):

Inclusive YYYY-MM-DD. Defaults to 30 days before publicationTo.

## `publicationTo` (type: `string`):

Inclusive YYYY-MM-DD. Defaults to today.

## `deadlineFrom` (type: `string`):

Keep notices with at least one deadline on or after this YYYY-MM-DD. When deadlineTo is also set, the same deadline must satisfy both bounds.

## `deadlineTo` (type: `string`):

Keep notices with at least one deadline on or before this YYYY-MM-DD. When deadlineFrom is also set, the same deadline must satisfy both bounds.

## `minimumValue` (type: `number`):

Keep notices with at least one estimate in minimumValueCurrency that reaches this amount. Must be supplied together with minimumValueCurrency; no FX conversion is performed.

## `minimumValueCurrency` (type: `string`):

Three-letter currency code used for minimumValue comparisons, for example EUR. Must be supplied together with minimumValue.

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

TED notice type codes, for example cn-standard. Codes are lowercased before matching, so uppercase input works.

## `language` (type: `string`):

Three-letter TED language code. Falls back to English, then the first available translation.

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

Matched opportunities, not raw TED rows. Sparse searches continue behind rejected rows.

## `relevance` (type: `object`):

Explainable positive and negative term weights. Base keyword and CPV matches add 10 and 8 points.

## `pageSize` (type: `integer`):

Advanced. Official maximum is 250.

## `maxPages` (type: `integer`):

Stop after this many fully handled TED pages and save a safe resume cursor.

## `maxScannedItems` (type: `integer`):

Bound raw source rows independently of the matched-item limit.

## `overlapDays` (type: `integer`):

Rechecks recently published notices so later edits are detected.

## `stateRetentionDays` (type: `integer`):

Forget digests older than this many days after a successful run.

## `maxStateEntries` (type: `integer`):

Keep state bounded by retaining only the most recently seen notice digests.

## Actor input object example

```json
{
  "mode": "incremental",
  "keywords": [],
  "cpvPrefixes": [],
  "buyerCountries": [],
  "placeCountries": [],
  "noticeTypes": [],
  "language": "eng",
  "maxItems": 1000,
  "relevance": {
    "include": [],
    "exclude": [],
    "minimumScore": 0,
    "requireIncludeMatch": false
  },
  "pageSize": 250,
  "maxPages": 100,
  "maxScannedItems": 10000,
  "overlapDays": 3,
  "stateRetentionDays": 180,
  "maxStateEntries": 5000
}
```

# Actor output Schema

## `dataset` (type: `string`):

Normalized, ranked, and change-classified opportunity rows.

## `summary` (type: `string`):

Counts, stop reason, query window, configuration hash, and attribution.

# 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("atlas-data/tender-opportunity-intelligence").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("atlas-data/tender-opportunity-intelligence").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 atlas-data/tender-opportunity-intelligence --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "command": "npx",
            "args": [
                "mcp-remote",
                "https://mcp.apify.com/?tools=atlas-data/tender-opportunity-intelligence",
                "--header",
                "Authorization: Bearer <YOUR_API_TOKEN>"
            ]
        }
    }
}

```

## OpenAPI specification

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