EU Procurement Buyer Intelligence
Pricing
Pay per usage
EU Procurement Buyer Intelligence
Track TED award notices from the buyer side with resolved or explicitly notice-level buyer identity, location, and classification evidence.
Turn official EU Tenders Electronic Daily (TED) award notices into a normalized, change-tracked stream of buyer-side intelligence: who is buying, where they sit, how they are classified, what they awarded, and to whom.
This Actor is built for public-sector sales teams, bid-intelligence analysts, market researchers, and policy teams who need to answer questions such as:
- Which public buyers in a country, NUTS region, or activity sector are awarding contracts?
- What does one buyer's award history, CPV mix, and spend profile look like?
- Which buyer types — local authorities, bodies governed by public law, health, education — are active in a category?
- Which buyers changed a published award, and what changed?
- Which award notices and buyer records should be loaded into a CRM, warehouse, or territory plan?
The reason to pay is not access to public TED data. It is the production workflow around it: safe filtering, bounded exhaustive traversal, deterministic normalization, evidence-based buyer attribution, stable IDs, deduplication, edit detection, replay-safe incremental state, rate limits, retry handling, and concurrency protection.
Output
Each dataset row is one award notice, with output schemaVersion 1.0.0:
- stable
recordId(SHA-256) derived only from the TED publication number, so it survives notice versions and content corrections; contentDigest, which changes when normalized source content changes and is separate from logical identity;- TED
noticeId,noticeVersion, canonical notice URL, title, language, form/notice type, and publication date; - a
buyerobject holding all buyer evidence — see Buyer attribution; - winner names, identifiers, countries, and selection statuses as a notice-level set;
- award dates, split into
decisionDates(winner-decision-date) andconclusionDates(contract-conclusion-date), plus their union inawardDates; - CPV codes, result-lot identifiers with stable
awardIdvalues, and contract identifiers; - notice-result and lot-result amount/currency sets without amount-to-currency or value-to-lot joins;
- procedure type and identifier;
- buyer/supplier/performance geography, including buyer NUTS codes;
associationCompletenessandassociationWarnings;- explainable relevance score/signals;
changeType,changedFields,fetchedAt, and explicit TED attribution.
Logical IDs are deterministic, source-namespaced, and independent of output schema version, notice version, and mutable content. They are not identifiers issued by TED.
Buyer attribution
TED's Search API flattens nested eForms structures, publishing each buyer attribute as its own independent array. Those arrays cannot be paired by position. Measured across 2,500 consecutive live award notices (full method and figures in EVIDENCE.md):
- 97.4% of notices published exactly one buyer name;
- among multi-buyer notices, 55% had at least one buyer facet whose length differed from the buyer-name count — including a notice with 20 buyer names against 40 buyer identifiers, and one with 2 buyer names against 3 main-activity codes and a single legal type.
buyer-name is a multilingual map, so buyers are counted as the organization names published
in the one selected output language. A single buyer whose name TED publishes in French, Dutch,
and English is one buyer, not three, and still resolves.
So the Actor emits one record per award notice and reports buyer evidence with an explicit attribution level, never a guessed join:
buyer.attribution | Meaning |
|---|---|
sole-buyer-resolved | TED published exactly one buyer name in the selected language and at most one value in every other buyer facet. Each attribute has only one organization it can belong to, so buyer.resolved is emitted with name, identifier, country, city, post code, NUTS code, legal type, main activity, profile URL, and internet address. associationCompleteness is buyer-resolved. |
notice-level-unpaired | The facets provably cannot be paired. No resolved object is emitted. Every facet remains an independent notice-level set. associationCompleteness is notice-level-only. |
buyer.facetCounts always publishes the raw per-facet value counts TED returned, so you can
audit exactly why a record did or did not resolve. Set requireResolvedBuyer: true to keep
only provably attributable records.
Note that a single buyer name is not on its own sufficient: 221 of the 2,436 sampled single-name notices published more than one buyer identifier. Those do not resolve.
main-activity and authority-main-activity are parallel encodings of the same concept and
are merged into mainActivities; a notice whose two activity fields disagree does not resolve.
Winner sets, lot identifiers, contract identifiers, and value sets are likewise preserved as separate notice-level sets and never joined to each other or to a buyer. This is intentionally conservative: for market intelligence, a fabricated buyer-to-contract link is worse than an explicit "cannot be determined".
Run modes
full emits every matching award notice as snapshot.
incremental emits only new and updated notices. It overlaps the last completed
publication watermark (three days by default) so later TED edits are detected.
diff tracks the same durable state but also emits unchanged notices for reconciliation
and warehouse snapshots.
The change digest is keyed by TED notice ID. A changed buyer attribute, winner, value set,
award date, lot identifier, procedure, URL, or association warning is reported as an update.
changedFields identifies the changed top-level normalized fields, so a buyer edit surfaces
as ["buyer"]. The stable recordId never changes while contentDigest does.
Safe filtering
Buyer-side filters:
- buyer/notice keywords, matched against buyer names and the notice title;
- buyer countries (alpha-3);
- buyer NUTS prefixes, matched against
buyer-country-sub—DEmatches all German regions; - buyer legal types, for example
laorbody-pl; - buyer main activities, for example
gen-pub,health,education; requireResolvedBuyerto keep only provably attributable buyer records.
Award-side filters:
- winner/supplier keywords and countries;
- CPV prefixes and performance countries;
- inclusive publication and award-date ranges, with one award date required to satisfy both bounds;
- minimum award value in one exact currency;
- TED result notice types;
- optional inclusion of award notices exposing no winner name;
- preferred output language with English and deterministic fallback.
Keyword groups use OR semantics; different filter groups combine with AND. minimumValue and
minimumValueCurrency must be supplied together and no FX conversion is performed. Because
TED values are flattened, the value filter is intentionally strict: only a scope containing
exactly one amount and exactly one currency can satisfy it.
User strings are never inserted into TED expert-query syntax. The server-side query contains
only strictly validated calendar dates plus the fixed, live-verified form-type = result
predicate. All variable business filtering happens against normalized rows inside the Actor.
An optional relevance profile adds or subtracts weights across title, buyer,
buyerActivity, buyerLegalType, winner, cpv, country, nuts, place, and
procedure. Every contribution is emitted in relevance.signals.
Example input
{"mode": "incremental","buyerCountries": ["DEU", "FRA"],"buyerNutsPrefixes": ["DE3"],"buyerMainActivities": ["gen-pub", "health"],"buyerLegalTypes": ["la"],"requireResolvedBuyer": true,"cpvPrefixes": ["72"],"publicationFrom": "2026-01-01","publicationTo": "2026-01-05","minimumValue": 250000,"minimumValueCurrency": "EUR","language": "eng","maxItems": 500,"relevance": {"include": [{ "term": "cloud", "fields": ["title"], "weight": 15 },{ "term": "health", "fields": ["buyerActivity"], "weight": 8 }],"exclude": [],"minimumScore": 0,"requireIncludeMatch": false}}
The publication window is kept to five days on purpose: every window must hold at most 6,800 TED result notices, and local filters do not shrink it. See below.
Bounded publication windows
One publication window must contain at most 6,800 TED result notices. This is a hard
contract, not a soft cap. Before any dataset row is written the Actor reads the window's
totalNoticeCount from TED's first page and fails the run with an explicit message if the
window is larger, on a newly opened window and on every resumed traversal of the same window.
The reason is durable state. A window is only replay-safe while its complete source ledger — what stops an already handled notice from hiding an unprocessed one after an iteration token expires — and the change digests it produces both fit the 3.5 MB state budget at the same time. Worst case each source notice costs one of each: 460 bytes of digest and 40 bytes of ledger. With 64 KiB reserved for the inbound token, configuration skeletons, and JSON scaffolding:
65,536 + N x (460 + 40) <= 3,500,000 => N <= 6,868 => 6,800 rounded down
TED publishes about 1,075 result notices per day EU-wide, so 6,800 is roughly a six-day window.
Server-side filtering is date plus form-type = result only — buyer, CPV, and country filters
run locally and do not reduce the source rows a window must traverse — so this bound applies
to every configuration equally.
What this means in practice:
- Split long date ranges. Run 2026-01-01…2026-01-05, then 2026-01-06…2026-01-10, and so on, rather than a single quarter or year. Each run is independent and the dataset is additive.
- The default window is deliberately short. With no
publicationFrom, the Actor looks back 5 days frompublicationTo(default: today). Inclusive of both endpoints that is six days of publications, about 6,450 notices at the evidenced rate — inside the bound, with headroom. A 6-day default would not be: seven days is roughly 7,525 notices. - Widen the window only with filters you have measured. Business filters run locally and do
not shrink the traversal, so a longer range needs splitting, not narrower keywords. Set
publicationFromandpublicationToexplicitly and keep each run inside the bound. - Schedule incremental runs often enough that the watermark overlap plus the gap since the last successful run stays under the bound — roughly every three days at current volumes.
The retained-digest count per configuration is fixed at 6,800 for the same reason: exactly the
per-window maximum, so a configuration can never prune away a digest belonging to the window it
is traversing and re-emit an unchanged notice as new. It is an internal constant rather than
an input — there is no value a caller could supply that is both safe and different.
Traversal, caps, and resume
The Actor uses TED ITERATION mode, with a maximum of 250 notices per page and 10,000
requested fields per page. The shipped field set is 33 fields, so the default page size of 250
stays inside the cap; a test asserts this bound. Traversal within a window is exhaustive; the
window itself is bounded as described above.
maxItems counts matched award notices, not source rows. Sparse searches continue behind
rejected notices. Cost and forward progress are independently bounded by maxPages and
maxScannedItems. Duplicate-only pages and rows revisited after an expired token do not
consume those forward budgets; replay has separate hard page/row ceilings to stop malformed
endless loops.
If a cap lands inside a page, durable state records the exact query window, run mode, original
page size, inbound opaque token, row offset, and compact source ledger. The next run completes
that same window even if the date, page size, or operational caps changed. Expired or invalid
iteration tokens replay the same window from its start; the ledger prevents already handled
rows from hiding an unprocessed row. Switching an unfinished incremental cursor to diff
restarts that window without the incremental source ledger so previously handled rows are
emitted as unchanged. Repeated tokens, replay ceilings, oversized pages, conflicting
duplicate notice versions, malformed envelopes, and timedOut: true fail closed.
HTTP 429, 5xx, and network errors receive bounded exponential-backoff retries. Requests are sequential and conservatively rate-limited. A failure after partial dataset output never advances durable state.
Write ordering and recovery
Before contacting TED, the Actor reserves whole-document space for the complete replay ledger of the bounded window it is about to traverse — not merely for the rows one run may add — so a window that starts is a window later runs can finish. If admission cannot fit the 3.5 MB durable-state budget, source processing and dataset writes do not start.
After admission, the write order is strict:
- dataset rows;
- default key-value-store
OUTPUTsummary; - durable cursor/digest state last.
A source, dataset, summary, lease, or state failure before step 3 leaves the work replayable.
Replays may duplicate already written dataset rows. Consumers should upsert by stable
recordId; use contentDigest, changeType, and fetchedAt to distinguish revisions. Do not
use noticeVersion or contentDigest as the logical upsert key.
The OUTPUT record includes scan/match/emission/duplicate counts, replayed page/row counts,
the resolvedBuyers / unpairedBuyers attribution split, change counts, stop reason, status
(succeeded or partial), query window, configuration hash, dataset ID, timestamp, and attribution.
Isolated bounded state and concurrency
Business state is keyed by a SHA-256 fingerprint of all selection, language, value, buyer, and relevance settings. Operational caps do not fork state. Full-snapshot and change-tracking resume cursors are separate.
This Actor uses its own resources, disjoint from any sibling Actor:
- named state store:
eu-procurement-buyer-intelligence-state-v1; - state key namespace:
EU_PROCUREMENT_BUYER_STATE_V1; - lock queue:
eu-procurement-buyer-intelligence-lock-v1.
State commits are immutable uniquely keyed snapshots. A persistent Request Queue entry provides exclusive writer leases, periodic prolongation, ownership checks, and server-side expiry. Snapshot selection compares semantic document revisions before key ordering so a stale writer cannot win merely through a later random key. Release is best-effort after a successful commit.
Each configuration keeps compact digests for 180 days by default, configurable from 7–730 days, and for a fixed, non-configurable 6,800 notices — exactly the per-window source maximum, so the window being traversed can never lose its own digests. The complete state document is measured against a conservative 3.5 MB budget; old completed configurations and then other configurations' old digests are pruned. The active configuration's ledger and digests are never eviction candidates: if they cannot be committed within the budget the save fails closed instead. Before a new run is admitted, old ledgers in other saved configurations or resume slots that would prevent a safe commit are converted to small recovery cursors: their exact saved windows remain, while token, offset, and source ledger are cleared so they safely restart from row zero when next selected. The selected active resume is never discarded for admission; if it cannot grow safely, the run fails before reading TED or writing dataset rows. Snapshot listing/cleanup is bounded and tolerant of eventual consistency; any repeatedly unreadable listed candidate that could be newest makes loading fail closed.
Schedule non-overlapping runs where possible. Separate dataset, OUTPUT, and state writes are
not one cloud transaction, so overlapping runs can still create replayed dataset rows even
though stale state commits are rejected.
Cost expectations
The default memory is 1024 MB, which consumes 1 compute unit (CU) per hour of runtime. At an example rate of $0.20/CU, a 10-minute run costs about $0.033 in compute and a one-hour run about $0.20, before storage operations and data transfer.
These are planning examples, not quoted prices. Actual rates depend on the Apify plan, discounts, pricing model, memory override, storage, transfer, source volume, and run duration. Start with a short publication window — it must hold at most 6,800 result notices in any case — and conservative caps, then inspect the run Usage details.
Limitations
- A single publication window may hold at most 6,800 TED result notices, about six days of EU-wide awards. Broader ranges are refused before any row is written and must be split across runs; local filters do not relax this, because filtering happens after traversal.
- Coverage and quality are limited to what the official TED Search API exposes. Source omissions and incorrect source data remain omissions/incorrect data.
- Roughly 2.6% of live award notices list more than one buyer. Those records are deliberately not
attributed to an individual buyer, so per-buyer aggregates built only from
sole-buyer-resolvedrecords will exclude them. Usebuyer.facetCountsandbuyer.attributionto decide how to treat them. - Notice-level award values belong to the notice, not to any one buyer or lot. Summing them per buyer across a multi-buyer notice would double count.
- Buyer identifiers may be national IDs, European IDs, UUID-like internal values, or other source strings. The Actor preserves them without relabeling their type, and does not resolve one organization across notices or countries.
- Buyer names are language-tagged source strings; the same organization may appear under different names or languages across notices. Buyer counting uses the selected language only, so translations of one name are never mistaken for several buyers.
- Values are not converted between currencies or inflation-adjusted.
- Stable record/award IDs do not change with notice versions or mutable content. A corrected
result-lot identifier is a different logical lot and receives a different
awardId. - This Actor is not legal, procurement, sanctions, or supplier-due-diligence advice.
Local development and release checks
Requires Node.js 22.
npm cinpm run lintnpm run typechecknpm run buildnpm testnpm run smoke:localnpm run smoke:livenpm audit --omit=devnpm auditapify validate-schema
npm run smoke:live boundedly scans up to 500 recent production TED award notices and asserts
the buyer-attribution invariants against real data — no notice-level-unpaired record may carry
resolved attributes, every sole-buyer-resolved record must come from a single published buyer
name — then validates both record shapes against the dataset schema. The normal
fixture/adversarial suite needs no network, cloud storage, credentials, or secrets.
The production Docker image builds TypeScript in an isolated Node 22 stage and installs only runtime dependencies in the final Apify Node 22 image.
Data source and attribution
Source: TED — Tenders Electronic Daily via the anonymous TED Search API v3.
The API is officially documented as anonymously accessible for published-notice reuse. Preserve the attribution included with every row and review the current TED legal notice for your use case. This Actor does not claim ownership of TED source data.