# UK Tenders API — Find a Tender + Contracts Finder in One Feed (`celestjux/celestjux-uk-tenders`) Actor

UK public-sector tenders and awards from Find a Tender + Contracts Finder in one feed; notices on both sources merged when title, buyer, region and value match. Filter by CPV, value, region, keyword; daily onlyNew alerts, including amendment re-sends.

- **URL**: https://apify.com/celestjux/celestjux-uk-tenders.md
- **Developed by:** [Celest Jux](https://apify.com/celestjux) (community)
- **Categories:** Lead generation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $3.00 / 1,000 notice records

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

## UK Tenders API — Find a Tender + Contracts Finder in One Feed

One clean, typed JSON feed of UK public-sector procurement notices from **both** official UK
sources: **Contracts Finder** (England, mostly below-threshold) and **Find a Tender** (UK-wide,
above-threshold, including every Procurement Act 2023 notice). A notice carried by both sources is
merged into a single item when its title, buyer, region and value match. A source that fails to
load stops the run before anything is written, so you never get a half-read feed. Filter by CPV code, value, region and keyword, and run it
daily with `onlyNew` for tender alerts/monitoring: only what's new since yesterday, plus amendment
re-sends (v1.1) when a delivered notice's deadline, value or status later changes.

Built on the two official, keyless OCDS APIs. No HTML scraping, no proxy, nothing to bypass.

> Contains public sector information licensed under the Open Government Licence v3.0.
> https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/

### What you get

| Field | Example | Notes |
|---|---|---|
| `id` | `find_a_tender:ocds-h6vhtk-077b05:tender` | `source:ocid:stage` — dedupe key, used by onlyNew |
| `ocid` | `ocds-h6vhtk-077b05` | OCDS procurement id (per source) |
| `source` | `find_a_tender` | or `contracts_finder` |
| `alsoIn` | `contracts_finder` or `null` | the same notice is also published on the other service |
| `stage` | `tender` | `tender`, `award` or `planning` |
| `noticeType` | `UK4` / `tenderAmendment` | FTS form code when given, else the OCDS release tag |
| `title`, `description` | | |
| `buyer` | `{"name": "East Riding of Yorkshire Council", "id": "GB-FTS-33279", "region": "Yorkshire and the Humber", "contactEmail": "…"}` | |
| `cpv` | `[{"code": "85000000", "description": "Health and social work services"}]` | every CPV code on the notice |
| `valueAmount`, `valueCurrency` | `8400000`, `GBP` | net value; awards without a tender value = sum of award values |
| `valueMin`, `valueMax` | `8000000`, `8400000` | Contracts Finder value ranges |
| `publishedDate` | `2026-09-25T23:16:45+01:00` | ISO 8601; latest publication/edit of the notice |
| `tenderDeadline` | `2026-10-26T12:00:00+00:00` | |
| `contractStart`, `contractEnd` | | |
| `region` | `London` | delivery region; FTS NUTS codes are turned into region names |
| `procedureType` | `Open procedure` | |
| `isSmeSuitable`, `isVcseSuitable` | `true` / `false` / `null` | buyer's suitability flags |
| `awards` | `[{"supplierName": "Tarmac Trading Limited", "supplierId": "GB-PPON-…", "value": 84000000, "date": "2026-08-26T00:00:00+01:00"}]` | award stage only (empty otherwise) |
| `noticeUrl` | `https://www.find-tender.service.gov.uk/Notice/091204-2026` | human page |
| `licence` | `OGL-UK-3.0` | on every item |
| `scrapedAt` | | UTC |
| `isUpdate` | `true` / `false` | onlyNew: true when this item replaces an earlier delivered release of the same notice |
| `previousReleaseId` | `"091204-2026"` / `null` | onlyNew update: the previously delivered release id, when known; null otherwise |
| `extraFields` | `{"legalBasis": "2023/54", "regionCodes": ["UKE12"], "alsoInUrl": "…"}` | release tags, release id, legal basis (Procurement Act 2023 vs PCR 2015), category, gross value, first-published date, raw region codes, tender status, submission URL, buyer phone, the other source's id + URL |

Missing values are `null`, never empty strings.

### Input examples

IT tenders over £100k this week:

```json
{ "cpvPrefixes": ["72"], "minValue": 100000, "includeNoValue": false, "publishedFrom": "7 days" }
```

Construction in London:

```json
{ "cpvPrefixes": ["45"], "regions": ["London"] }
```

Daily alert (schedule once a day):

```json
{ "stages": ["tender"], "publishedFrom": "2 days", "onlyNew": true }
```

Last month's awards with supplier names:

```json
{ "stages": ["award"], "publishedFrom": "1 month", "maxItems": 5000 }
```

### How filters work

- `publishedFrom` / `publishedTo` — `YYYY-MM-DD` or relative (`7 days`, `2 weeks`, `1 month` =
  that long before today, UK time). Default `7 days`. Contracts Finder filters on "published or
  last edited"; Find a Tender only offers "last updated", which is what it uses.
- `stages` — Contracts Finder filters stages server-side. **Find a Tender's own stage filter drops
  every Procurement Act 2023 notice** (checked 2026-09-27: `stages=tender` returned only old-regime
  PCR 2015 notices), so this actor reads all Find a Tender notices in the window and filters stages
  itself. Amendments, updates and cancellations belong to their stage; contract-performance
  (`implementation`) notices are out of scope.
- `keyword`, `cpvPrefixes`, `minValue`/`maxValue`, `regions` — neither API supports these, so
  they are applied by the actor (client-side) after download.
  - `cpvPrefixes`: matches if any CPV code on the notice starts with any prefix.
  - Value filters compare `valueAmount` in GBP. Notices with no value (or a non-GBP value) are kept
    only if `includeNoValue` is on (default on).
  - `regions`: region names (`London`, `North West`, `Scotland`, …) or NUTS/ITL code prefixes
    (`UKI`, `UKD3`, `TLI`). Matches delivery regions and the buyer's region.
- One item per procurement and stage per source: when a notice has been amended in the window, you
  get the latest version (`noticeType` tells you it was an amendment).
- Results from both sources are merged newest-first and cut at `maxItems`.

### Cross-source dedupe

The two services give the same notice different ocids and do not cross-reference each other. A
notice counts as the same in both when **stage, title, buyer name and tender deadline** match
(case/punctuation-insensitive) **and** that combination is unique within each source — buyers often
reuse one title for many separate notices (e.g. one council published 9 different taxi-route lots
under the same title in one week), so ambiguous matches are never merged. Records with conflicting
GBP values are also kept separate. Cross-source matching runs only when both source windows were
fully read; a `maxItems` or charge cap may leave two copies in the limited output. The Find a Tender
copy is kept, `alsoIn` is set to `contracts_finder`, and the Contracts Finder id and URL go to
`extraFields.alsoInId` / `alsoInUrl`. Overlap is small: since the Procurement Act 2023 most
above-threshold notices appear on Find a Tender only.

Contracts Finder sometimes prefixes its title with an internal reference code (e.g. `CA18485 -
Hopwood Hall College - Provision of HR & Payroll System`, where Find a Tender's copy of the same
notice is titled `Hopwood Hall College - Provision of HR & Payroll System`). The matching step
strips a leading reference-code prefix — one of the letter families actually seen across every
Contracts Finder title captured during this actor's development (`C`, `CA`, `ENQ`, `PR`, `PS`,
`UOW`, each followed by 2+ digits; `T0nnn` on its own, the exact shape observed — not `T` plus any
digits, which would also catch unrelated titles like "T20 - ...") — then a `-`/`–`/`:`
separator — before comparing titles, but only for the match; the notice's own `title` field always
keeps the prefix as published. This is deliberately conservative: the letter families are
constrained to what was actually observed (plus an explicit denylist for standard/product names
like ISO or NHS reference numbers that could otherwise look like a code), a prefix followed only by
a space (no punctuation), a location prefix (`GB-London: ...`), or a title that's nothing but a
reference code are all left alone rather than guessed at. It reduces common duplicates from this
pattern; it doesn't guarantee every duplicate is caught, and in principle two distinct notices
could still share a match key if they coincidentally matched on stage, buyer and deadline too.

### onlyNew

Pushed notices are fingerprinted in the named key-value store `celestjux-uk-tenders-seen` (it
persists between runs): `{releaseId, publishedDate}` per notice id. A later run skips a notice only
when the store holds the *same* release (same release id, or a same-or-older `publishedDate`) — a
genuinely later release of an already-delivered notice (an amendment that moves the deadline,
changes the value, or cancels the tender) is pushed again, with `isUpdate: true` and
`previousReleaseId` set to the release it replaces. Set `resendUpdates: false` to go back to the old
behaviour: any previously delivered notice id is always skipped, updates included.

A copy from either source of an already-delivered notice is still skipped when both have the same
GBP value and the first source window was fully read (unchanged — see Cross-source dedupe above).

A `tenderCancellation` of an already-delivered notice arrives as an update too (`isUpdate: true`,
`previousReleaseId` set) — check `noticeType` to tell it apart from an amendment.

The newest-release guarantee only covers pages actually read: once a source has enough candidates
to fill `maxItems` (or the charge cap), the run stops reading that source's remaining pages, so an
even-newer release of an already-kept notice past that cutoff is not seen. In practice the live
feed is newest-first, so this rarely bites.

Charging: a re-sent update is a normal `record` event like any other pushed item — amendments of a
delivered notice are delivered again, charged as a record; set `resendUpdates=false` to skip them.

To start over, delete the `celestjux-uk-tenders-seen` store.

### Daily schedule → Slack / Sheets / webhook

1. Save an input like the "daily alert" example as a task.
2. Console → Schedules → daily (e.g. 07:00 Europe/London) → add the task.
3. Integrations on the task: Slack (post new items), Google Sheets (append rows), or a webhook on
   "Run succeeded" pointing at your system; it receives the run with its dataset id.

### Pricing

Pay per event: **$0.003 per notice pushed** (= $3 per 1,000). No start fee. Set `maxTotalChargeUsd`
to cap a run; it stops cleanly before going over.

### Reliability

- Sequential requests, ≥ 1 s apart, identifying user agent.
- Timeouts and 5xx: 3 attempts with backoff.
- Find a Tender 429/503: honours `Retry-After` up to 4 times per request. A malformed
  `Retry-After` (missing, non-numeric, negative, `NaN`/`Infinity`, or bigger than 300s) is never
  guessed at — it fails the run loud immediately, naming the URL and the raw header value.
- All rate-limit waits — Find a Tender's `Retry-After` waits **and** a Contracts Finder 403
  cooldown — count against one shared cap on total wait per run (default 600s/10min, set
  `maxRateLimitWaitSec` to change it). If honouring a wait would push the total over that cap, the
  run fails loud before sleeping rather than after. A 429/403 with a usable, in-range wait usually
  just recovers on its own — the run only fails once the per-request cap (4) or the run-wide cap is
  actually reached.
- After the first 429/403 of a run, the delay between pages increases for the rest of the run to
  ease off the source.
- Contracts Finder 403 (its rate limit): one 5-minute cooldown, then the run fails if it happens
  again — unchanged behaviour, now counted in the shared wait budget/stats above and triggering the
  same slower page pace as a Find a Tender 429.
- A source fetch or parse failure fails the run with the URL in the error, before any item is
  written — items are pushed only after every source has been read completely.
- Run stats include `rateLimitWaits` and `rateLimitWaitSec` (both sources combined) so you can see
  how much of a run's time went to honouring a rate limit.

### Limits

- v1 has no Companies House enrichment of suppliers (planned v1.1: bring your own Companies House
  API key).
- Contracts Finder's planning stage is practically empty (0 notices in the week checked); pipeline
  notices come from Find a Tender.
- A long window on Find a Tender means many pages (roughly 500 notices of all types per weekday),
  since its stage filter cannot be used.

# Actor input Schema

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

Contracts Finder (England, mostly below-threshold) and/or Find a Tender (UK-wide, above-threshold, Procurement Act 2023).

## `stages` (type: `array`):

tender = open opportunities (incl. amendments/cancellations); award = awarded contracts with suppliers; planning = pre-market / pipeline notices. Reading more stages means more Find a Tender API pages per run — combine with a short publishedFrom window (see below) to avoid its rate limit.

## `publishedFrom` (type: `string`):

YYYY-MM-DD, or relative like '7 days' / '2 weeks' / '1 month' (= that long before today, UK time). Contracts Finder: published or last edited; Find a Tender: last updated. This is NOT the tender's closing/deadline date — there is no field to filter by tenderDeadline; fetch a window and check each item's tenderDeadline yourself. A wide window with multiple stages/sources reads more Find a Tender API pages and risks its rate limit; a 429 with a usable Retry-After is retried automatically (up to 4 times per request, 10min total per run by default — see maxRateLimitWaitSec), so it usually recovers on its own — the run only fails once that cap is reached, with no partial dataset. Keep the window to 7 days or less to make hitting the cap unlikely.

## `publishedTo` (type: `string`):

Optional end date (inclusive) on the same publication/last-updated date as publishedFrom, not on tenderDeadline. Empty = up to now.

## `keyword` (type: `string`):

Only notices whose title or description contains this exact text (case-insensitive substring, filtered by this actor — the APIs have no keyword search). For a category of work rather than an exact phrase (e.g. "IT services", "construction"), prefer cpvPrefixes: it matches the notice's official classification instead of literal wording, so it won't miss notices that don't happen to use your words.

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

Match any CPV code starting with one of these digit prefixes. Common ones: 72 = IT services (software, hosting, cloud, support), 48 = software packages, 45 = construction, 79 = business services (incl. consulting, HR, admin), 71 = architecture/engineering, 85 = health/social work. Prefer this over keyword when the ask names a category of work.

## `minValue` (type: `number`):

Keep notices with value >= this. Non-GBP and valueless notices follow 'Include notices without value'.

## `maxValue` (type: `number`):

Keep notices with value <= this.

## `includeNoValue` (type: `boolean`):

When a min/max value is set, still keep notices that publish no (GBP) value.

## `regions` (type: `array`):

Region names (e.g. London, North West, Scotland) or NUTS/ITL code prefixes (e.g. UKI, UKD3, TLI). Matches delivery region or buyer region.

## `onlyNew` (type: `boolean`):

Skip notices delivered by an earlier onlyNew run (remembered in KV store celestjux-uk-tenders-seen). Amendments to an already-delivered notice are re-sent (see resendUpdates). Only detects a change relative to a PREVIOUS onlyNew run of this same actor — it has no baseline for a notice it has never seen before, so it can't answer "what changed since last week" the first time it looks at a tender; run it on a schedule to build that history.

## `resendUpdates` (type: `boolean`):

With onlyNew: when a later release of an already-delivered notice appears (amendment, cancellation, deadline/value change), send it again with isUpdate=true. false = old behaviour, a seen notice id is always skipped.

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

Hard cap on items pushed.

## `maxTotalChargeUsd` (type: `number`):

Optional extra spending cap under the platform's own. Run stops cleanly before exceeding it.

## `maxRateLimitWaitSec` (type: `integer`):

Cap on total time spent on all rate-limit waits across the whole run — Find a Tender's Retry-After waits AND a Contracts Finder 403 cooldown, added together (default 600s/10min). Up to 4 Retry-After waits are honoured per request; hitting either that per-request cap or this run-wide one fails the run loud (URL + wait counts in the error, no partial dataset).

## Actor input object example

```json
{
  "sources": [
    "contracts_finder",
    "find_a_tender"
  ],
  "stages": [
    "tender"
  ],
  "publishedFrom": "7 days",
  "includeNoValue": true,
  "onlyNew": false,
  "resendUpdates": true,
  "maxItems": 1000
}
```

# Actor output Schema

## `overview` (type: `string`):

No description

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

No description

# 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("celestjux/celestjux-uk-tenders").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("celestjux/celestjux-uk-tenders").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 celestjux/celestjux-uk-tenders --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,celestjux/celestjux-uk-tenders"
        }
    }
}
```

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/RWOPp7nSXdOC9ofxX/builds/eIWRO1ko9RnIObK67/openapi.json
