Tender Opportunity Intelligence
Pricing
Pay per usage
Tender Opportunity Intelligence
Normalize, deduplicate, rank, and track EU procurement opportunities from TED.
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;- 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, orunchanged) and top-levelchangedFields;fetchedAtand 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
{"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
OUTPUTwere written but its state commit failed, the next run re-emits the same logical rows with identicalemissionIdvalues even thoughfetchedAtdiffers. Upserting onemissionIdand keeping the newestfetchedAtconverges 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
- was validly normalized from a TED notice,
- passed every business filter,
- scored at least
relevance.minimumScore, - produced an include signal when
requireIncludeMatchis on, - is emitted by the run mode, and
- 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"andstatus: "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
maxTotalChargeand resumes past it. - A row could not be charged. The row stays available and free. The run stops with
stopReason: "chargeError", reportsbilling.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
abortingormigrating, the run stops at the next row boundary withstopReason: "gracefulAbort", and the Actor holds the container's shutdown or migration reboot open untilOUTPUTand 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
emissionIdwithin one Actor run, andemissionIdis 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 onemissionId. - 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
fullsnapshot and a settleddiffrow 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:
{"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.
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 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 and the anonymous TED Search API.
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 for your use case. This Actor does not claim ownership of TED source data and does not provide legal or procurement advice.