# UK Tenders Scraper - Contracts Finder & Find a Tender (OCDS) (`neverempty/uk-tenders-scraper`) Actor

For bid teams and public-sector sales: UK tenders and contract awards from both official services at once, Contracts Finder and Find a Tender. On 2026-09-09 alone that was 580 notices (136 plus 444). Buyer, CPV, value, deadline, region and winning supplier, duplicates merged. No API key.

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

## Pricing

from $7.30 / 1,000 tender returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

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

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

## UK Tenders Scraper - Contracts Finder & Find a Tender (OCDS)

**For suppliers bidding for public work, bid and framework research, and competitor award tracking**: **UK public sector tenders and contract awards from both official services in one table** — the buyer, CPV codes, value and currency, deadline, delivery region, procedure, status and the winning suppliers, one row per procurement.

**A supplier watching one service misses the other.** The UK publishes procurement notices on **Contracts Finder** (lower-value opportunities and awards) and on **Find a Tender** (the central digital platform under the Procurement Act 2023, including above-threshold notices). This Actor reads the **official OCDS APIs** of both, compiles every notice about the same procurement into one row, and merges the tenders that appear on both — a merged row lists both OCIDs, both notice links and `foundOnBothServices: true`, and takes the more advanced state of the two.

**Measured on the full set of notices published or updated on 2026-09-09: 136 Contracts Finder releases and 444 Find a Tender releases**, read page by page, kept in the repository as a test fixture. On that one day, **8 Contracts Finder procurements and 23 Find a Tender procurements published more than one notice** — so a row keeps the value from the tender notice and the supplier from the award notice instead of showing you whichever arrived last. Across a 7-day run (2026-09-05 to 2026-09-11), **14 tenders were merged on an exact match and 19 of the 20 further pairs** a reviewer found were caught once the two services' title conventions were normalised — and a pair is merged **only when enough matches**, because one council published **5** transport-route notices with identical titles and buyer on a single day.

**Two of the services' own filters quietly lie, so this Actor does not use them.** Contracts Finder **silently ignores** a `keyword` parameter — asked for `software` it returned the same 100 notices in the same order as with no keyword, and only **13** of them mention software. Find a Tender's `stages` filter returns only part of the stage: **11 of the 95** tender-stage notices of that day, **45 of 283** award-stage, **3 of 55** planning.

**It also does not stop at Contracts Finder's first 100 notices.** That service sometimes returns exactly **100** notices and no link to the next page even when there are more; this Actor treats a full page with no next link as a cut list, splits the time window in half and reads both halves, until every window returns fewer than 100. And a value of `0` or `1` pound — a placeholder some buyers type (**3** notices on 2026-09-09) — is returned as `value: null` with `valuePlaceholder: true`, never as a price.

No API key. No proxy. No browser. Data under the Open Government Licence v3.0. Export as JSON, CSV or Excel.

### What one row looks like

A real row from 2026-09-09 (Find a Tender, notice `085589-2026`):

| Field | Value |
|---|---|
| `title` | `Maritime Domain Analysis and Reporting` |
| `buyerName` | `Home Office` |
| `tenderState` | `open` |
| `value` / `valueGross` / `currency` | `100000` / `120000` / `GBP` |
| `deadline` | `2026-09-22T12:00:00+01:00` (the clock time is kept) |
| `cpvCodes` | `["72316000","35720000"]` |
| `procurementMethodDetails` | `Below threshold - open competition` |
| `services` | `["find-a-tender"]` |
| `noticeUrl` | `https://www.find-tender.service.gov.uk/Notice/085589-2026` |

### What this Actor gets right (measured on 2026-09-11)

All numbers below come from the full set of notices published or updated on **2026-09-09**: 136 Contracts Finder releases and 444 Find a Tender releases, read page by page. That set is included in the repository as test data, and the tests recount it.

#### 1. One tender is one row, even when it was published three times

A procurement produces several notices: the tender, a correction, the award, an update to the award. On 2026-09-09 alone, 8 Contracts Finder procurements and 23 Find a Tender procurements published more than one notice. Award notices on Find a Tender often do not repeat the value or the deadline.

This Actor compiles the notices of one procurement with the OCDS merge rules (later notices win, and awards are matched by id), so a row keeps the value from the tender notice and the supplier from the award notice. **580 notice releases became 540 tenders, with no tender returned or charged twice.** A supplier re-published with the same amount under a new award id is counted once in `awardedValue`.

#### 2. A tender on both services is returned once, and never merged with a different lot

The two services give the same tender **different OCIDs** (`ocds-b5fd17-...` on Contracts Finder, `ocds-h6vhtk-...` on Find a Tender), and neither links to the other: measured over the whole day, **0 shared OCIDs and 0 cross-references**. So the same tender can only be matched by title and buyer.

The two services also write the same tender differently. Contracts Finder puts a reference in front of many titles (`CA18103 - Adult Skills Fund Course Delivery` against `Adult Skills Fund Course Delivery`) and adds ` - AWARD` to some award titles; buyer names differ in the same way (`The Council of the Borough of Kirklees` against `Kirklees Council`, `... LIMITED`, `DVSA - Driver and Vehicle Standards Agency` against `Driver & Vehicle Standards Agency`). This Actor removes those differences before comparing.

That match is still dangerous: one council publishes several lots with the same title (on 2026-09-09, one council published 5 transport route notices with identical titles and buyer). So a pair is merged **only when exactly one tender on each service carries that title and buyer**.

Measured on every row of a 7-day run (2026-09-05 to 2026-09-11): 14 tenders were merged by the exact match, and a reviewer found 20 more pairs published on both services. With the differences above removed, **19 of those 20 are now merged and the 14 stay merged**. The one left is a Contracts Finder notice whose buyer name was cut short on the notice itself (`University Hospitals Sussex NHS Foundati`), so it can still appear twice, once per service. Tenders that do not match on title and buyer after this cleaning are not merged, so "one row per tender" holds for matching titles and buyers, not for every possible duplicate.

A merged row lists both OCIDs, both notice links, and `foundOnBothServices: true`, and takes the more advanced state of the two (a tender still "active" on Contracts Finder but awarded on Find a Tender is returned as `awarded`). When the two services publish different values, `value` is the value of the service named in `valueSource` and the other service's value is kept in `valueOnOtherService` (measured: `Non-Domestic EPCs` is 15,000 on Find a Tender and 25,000 on Contracts Finder).

#### 3. Keywords and stages are filtered here, because the services do not filter them honestly

- Contracts Finder **silently ignores** a `keyword` parameter: asked for `software`, it returned the same 100 notices in the same order as with no keyword, and only 13 of them mention software. Find a Tender rejects it.
- Find a Tender's `stages` filter **returns only part of the stage**: 11 of the 95 tender-stage notices of the day, 45 of 283 award-stage notices, 3 of 55 planning notices.

So this Actor reads every notice of the chosen days and applies keywords (whole words, in title and description), CPV, stage, value, region and buyer itself. The `input` column of every row says exactly what was searched.

#### 4. Values and deadlines are not invented

- A value of `0` or `1` pound is a placeholder some buyers type (3 notices on 2026-09-09). It is returned as `value: null` with `valuePlaceholder: true`, never as a price.
- `deadline` keeps the time of day and `deadlineStatus` is `open`, `closed` or `not-published`. A tender whose notice still says "active" but whose deadline has passed is `tenderState: closed`, not `open`.
- A notice without a value or deadline returns `null`, not `0`.

#### 5. It does not stop at Contracts Finder's first 100 notices

Contracts Finder sometimes returns **exactly 100 notices and no link to the next page**, even when there are more. Measured on 2026-09-11: asked for all of 2026-09-10, it returned 100 notices published between 10:07 and 18:31 and no next page; asked for 00:00 to 10:07 of the same day, it returned 8 more. A 7-day request ended the same way, after 100 notices from a single day. A tool that follows the next link reads 100 and stops, without an error.

This Actor treats a full page without a next link as a sign that the list was cut, splits that time window in half and reads both halves again, until every window returns fewer than 100. A notice returned twice by overlapping windows is counted once. For 2026-09-10 that gives 108 notices, the same number as counting the day in four 6-hour windows by hand; for the 7 days to 2026-09-11 it gives 498 Contracts Finder notices instead of 100.

#### 6. It reads both services slowly enough not to be blocked

Both services answer **"Rate limit of 12 exceeded. Please retry after 120 seconds"** (HTTP 429) when asked too quickly. Measured on 2026-09-11: with 6.5 seconds between requests, every 7-day run was stopped once or twice, the first time 104 to 130 seconds in. This Actor now leaves 10.5 seconds between requests and, if it is still told to wait, waits exactly as long as the service asks.

Measured run times, so you can plan for them:

| Days read | Notices read | Requests | Waits imposed by the services | Run time |
|---|---|---|---|---|
| 1 (2026-09-09) | 580 | 7 | 0 | 45 seconds |
| 7 (2026-09-04 to 2026-09-10) | 3,125 | 38 | 1 | 533 seconds (about 9 minutes) |

A 31-day run reads roughly 150 pages, so expect around half an hour. If a run comes close to its time limit, it stops reading, returns what it has read and adds a row naming the days it did not read.

#### 7. It never returns an empty table without saying why

| `status` | Meaning | Charged? |
|---|---|---|
| `ok` | A tender | **Yes** |
| `no-results` | Both services answered and nothing matched. The row says how many notices were read and which filters can drop a tender | No |
| `failed` | A service could not be read. If the other one could, its tenders are still returned, and the row says so. Only a temporary failure (no answer, a request limit, a server error) is described as likely to work later | No |
| `incomplete` | Some pages were read, then a page failed, the page limit was reached, or the run came close to its time limit. What was read is returned, and the row names the periods that were not read | No |
| `bad-input` | The input was rejected before anything was sent (a wrong date, a CPV code that is not 8 digits) | No |

### How complete the columns are

Measured on the 540 tenders of 2026-09-09. The services publish these fields unevenly, and this Actor returns `null` rather than guessing:

| Column | Filled |
|---|---|
| `title`, `buyerName`, `buyerEmail`, `noticeUrl` | 540 of 540 (100%) |
| `suppliers` (winning companies) | 375 of 540 (69%) |
| `cpvCodes` | 307 of 540 (57%) |
| `deliveryRegions` | 218 of 540 (40%) |
| `value` | 202 of 540 (37%) |
| `deadline` | 202 of 540 (37%) |
| `buyerPhone` | 155 of 540 (29%) |

Contracts Finder fills value and deadline far more often than Find a Tender: in notice releases, 81% and 99% on Contracts Finder against 26% and 18% on Find a Tender.

### Input

| Field | Default | What it does |
|---|---|---|
| `keywords` | none | Words to find in the title or description, as whole words, ignoring case. A tender matches when it contains any of them |
| `cpvCodes` | none | 8-digit CPV codes. `72000000` matches every code under it, such as `72212000`. Main and additional CPV codes are both checked |
| `publishedFrom` | 7 days ago | First day, `YYYY-MM-DD`, UK time. Both services select notices by the date they were published or last changed |
| `publishedTo` | today | Last day, `YYYY-MM-DD`, UK time. At most 31 days per run |
| `sources` | both | `contracts-finder`, `find-a-tender`, or both |
| `stages` | all | `planning`, `tender`, `award`. The stage is the most advanced one reached in the notices read |
| `onlyOpen` | `false` | Keep only tenders open for bids: an active call for tenders (`tenderState: open`) whose deadline is in the future. Planning notices, awards and cancellations are dropped even when they show a future date, and so are tenders without a published deadline |
| `minValue` / `maxValue` | none | Value range in pounds. Tenders without a value in pounds are dropped when a range is set |
| `regions` | none | Words matched against the delivery region (Find a Tender's NUTS codes such as `UKI32` are turned into names such as `London`). If a tender names no delivery region, the buyer's region is used |
| `buyers` | none | Words in the buyer name, such as `NHS` or `Home Office` |
| `maxResults` | `200` | The most tender rows returned, most recently updated first. You are charged per tender row |

With no dates given, the last 7 days are read.

### Output columns

`services`, `foundOnBothServices`, `ocid`, `otherOcids`, `title`, `description`, `buyerName`, `buyerId`, `buyerEmail`, `buyerPhone`, `buyerRegion`, `stage`, `tenderState` (`planned`, `open`, `closed`, `awarded`, `cancelled`, `terminated`), `tenderStatusRaw`, `cpvMain`, `cpvMainDescription`, `cpvCodes`, `value`, `valueGross` (including VAT, when published), `valueMin` (lower end of a range), `currency`, `valuePlaceholder`, `valueSource` (which service the value comes from), `valueOnOtherService` (the other service's value, on merged rows where both publish one), `deadline`, `deadlineUtc`, `deadlineStatus`, `daysUntilDeadline`, `deliveryRegions`, `deliveryRegionCodes`, `procurementMethod`, `procurementMethodDetails`, `aboveThreshold`, `suppliers`, `supplierIds` (Companies House numbers appear as `GB-COH-...`), `awardedValue`, `awardedCurrency`, `awardDate`, `firstNoticeInWindow`, `lastUpdated`, `releaseTags`, `releasesSeen`, `noticeUrl`, `noticeUrls`, `checkedAt`, plus `status`, `note` and `input`.

A row reflects the notices published in the chosen days. If a tender was published last month and only its award appeared this week, the row carries what the award notice and any other notice in the window say.

### Personal data

The buyer contact returned is the **email and telephone** that the contracting authority publishes on the official notice. The separate contact-name field the services also publish is **not** returned. That does not make the rows free of names: the published email is often a named officer's work address (measured on 2,849 rows of a 7-day run: 1,143, or 40%, have the form `first.last@...`), and `title` and `description` are the buyer's own text, returned as published, so they can mention people. Suppliers are returned by company name and registration identifier.

### Sources and licence

- Contracts Finder OCDS search API, `contractsfinder.service.gov.uk`
- Find a Tender OCDS release package API, `find-tender.service.gov.uk`

Both are published by the Cabinet Office under the [Open Government Licence v3.0](http://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/). Contains public sector information licensed under the Open Government Licence v3.0. This Actor is not affiliated with the UK government.

### Support

Found a notice that looks wrong, or need another field? Open an issue in the **Issues** tab.

# Actor input Schema

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

Words to look for in the title and description of each notice, for example software or cleaning. A notice is kept when it contains any of the keywords, as a whole word, ignoring case. Neither service can search by keyword in its open data feed, so the notices published in the chosen days are read and filtered here.

## `cpvCodes` (type: `array`):

CPV classification codes, for example 72000000 (IT services) or 45000000 (construction work). A code matches itself and every code under it: 72000000 also finds 72212000. A notice is kept when its main or any additional CPV code matches.

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

YYYY-MM-DD (UK time). Leave empty to read the last 7 days. Both services filter by the date a notice was published or last changed, so an older tender that was amended in this window is included, with its latest state.

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

YYYY-MM-DD (UK time). The whole day is included. Leave empty for today.

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

Which services to read: contracts-finder, find-a-tender, or both. The same tender published on both is returned once, with every service it appeared on.

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

Any of planning (early market engagement and pipeline notices), tender (open opportunities), award (contracts awarded, with the winning suppliers). Empty means all three.

## `onlyOpen` (type: `boolean`):

Keep only tenders open for bids: an active call for tenders (not a planning notice, an award or a cancellation) whose deadline is in the future. Notices that publish no deadline are dropped when this is on, because they cannot be shown to be open.

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

Keep only notices whose published value is at least this many pounds. Notices without a value, or with a value in another currency, are dropped when a value range is set.

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

Keep only notices whose published value is at most this many pounds.

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

Words matched against the delivery region of the notice, for example London, Scotland or North West. A notice is kept when any region matches.

## `buyers` (type: `array`):

Words in the buyer (contracting authority) name, for example NHS or Home Office. A notice is kept when its buyer name contains any of them.

## `maxResults` (type: `integer`):

The most tenders returned. You are charged per tender row, so this is your cost ceiling.

## Actor input object example

```json
{
  "keywords": [
    "software"
  ],
  "sources": [
    "contracts-finder",
    "find-a-tender"
  ],
  "onlyOpen": false,
  "maxResults": 200
}
```

# Actor output Schema

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

One row per UK public procurement process (tender, award, cancellation) from Contracts Finder and Find a Tender: title, buyer, CPV classification, value and currency, publication date, deadline, region, procedure, status, winning suppliers when awarded, the link to the official notice and which service it came from, plus one row when an input was rejected, nothing matched, or a service could not be read.

# 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 = {
    "keywords": [
        "software"
    ],
    "sources": [
        "contracts-finder",
        "find-a-tender"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("neverempty/uk-tenders-scraper").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 = {
    "keywords": ["software"],
    "sources": [
        "contracts-finder",
        "find-a-tender",
    ],
}

# Run the Actor and wait for it to finish
run = client.actor("neverempty/uk-tenders-scraper").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 '{
  "keywords": [
    "software"
  ],
  "sources": [
    "contracts-finder",
    "find-a-tender"
  ]
}' |
apify call neverempty/uk-tenders-scraper --silent --output-dataset

```

## MCP server setup

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

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/CoGYDnv48ijzbzAsb/builds/LhQ1avhuEgTwCdSnQ/openapi.json
