# UK Public Tenders Scraper — Contracts Finder + Find a Tender (`timeless_enigma/uk-public-tenders`) Actor

Get UK government tenders, contract opportunities and awards from the official Contracts Finder and Find a Tender (FTS) OCDS APIs. Filter by keyword, CPV code, region, value, deadline and SME suitability. No login, no API key, Open Government Licence data.

- **URL**: https://apify.com/timeless_enigma/uk-public-tenders.md
- **Developed by:** [timeless_enigma](https://apify.com/timeless_enigma) (community)
- **Features:** Limited permissions
- **Stats:** 3 total users, 2 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

Pay per usage

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

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

## 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 Public Tenders Scraper — Contracts Finder + Find a Tender

Get **UK government tenders, contract opportunities and contract awards** from the two official UK portals in one run:

- **Contracts Finder**: mostly lower-value contracts in England, plus SME-suitability flags.
- **Find a Tender (FTS)**: UK-wide, higher-value contracts.

You get clean JSON, CSV or Excel. Filter by **keyword, CPV code, region, contract value, deadline and SME suitability**. Use it to find bids to chase, monitor buyers, or see **who won which contract for how much**.

✅ Official Cabinet Office OCDS APIs · ✅ No login, no API key, no browser · ✅ [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/) data · ✅ Buyer contact-person blocks dropped, emails and phone numbers in text redacted (see [Personal data](#personal-data-uk-gdpr))

### What you can do with it

- **Bid pipeline:** pull every open tender in your sector (e.g. CPV `72` IT services) with deadlines and values, every morning.
- **Win/loss intelligence:** run with `stage = award` to see awarded suppliers and award values.
- **Market research:** count tenders by buyer, region or category over a date range (see [Realistic date ranges](#realistic-date-ranges)).
- **Feed an AI agent or CRM:** stable, flat field names; schedule it, and use webhooks or integrations to push results to Sheets, Slack, etc.

### Input

| Field                           | Type          | Default                | Notes                                                                                                                                                                                                                                                                                                                                |
| ------------------------------- | ------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `sources`                       | array         | both                   | `contractsFinder`, `findATender`                                                                                                                                                                                                                                                                                                     |
| `stage`                         | string        | `tender`               | `tender` (open opportunities: tender, tenderUpdate, tenderAmendment; **cancellations excluded**), `award` (winners: award\*/contract\*), `planning` (pipeline), `all` (everything, incl. cancellations)                                                                                                                                |
| `includeCancellations`          | boolean       | false                  | When false (default), any procedure (OCID) that has a `tenderCancellation` release is dropped entirely, including its older tender notices, so a cancelled tender never appears as open. When true, cancellations and their earlier notices are returned (Find a Tender: 37 cancellation releases in the week of Oct 28–Nov 4, 2026) |
| `daysBack`                      | integer       | 7                      | Used when `publishedFrom` is empty: whole UTC days back from `publishedTo` (0 = current UTC day only)                                                                                                                                                                                                                                |
| `publishedFrom` / `publishedTo` | YYYY-MM-DD    | `daysBack` ago / today | Contracts Finder filters by publish date; Find a Tender by last-updated date                                                                                                                                                                                                                                                         |
| `keywords`                      | array         | –                      | Keep a notice if its title or description contains **any** of these (case-insensitive)                                                                                                                                                                                                                                               |
| `excludeKeywords`               | array         | –                      | Drop matches                                                                                                                                                                                                                                                                                                                         |
| `cpvPrefixes`                   | array         | –                      | e.g. `72` IT, `45` construction, `85` health, `79` business services                                                                                                                                                                                                                                                                 |
| `regions`                       | array         | –                      | Substring match on region names (`London`) or ITL/NUTS codes (`UKI`, `UKM`)                                                                                                                                                                                                                                                          |
| `minValue` / `maxValue`         | integer (GBP) | –                      | Notices without a value are dropped when these are set                                                                                                                                                                                                                                                                               |
| `onlyOpen`                      | boolean       | false                  | Only notices with a submission deadline still in the future                                                                                                                                                                                                                                                                          |
| `smeSuitableOnly`               | boolean       | false                  | Only notices the buyer marked SME-suitable                                                                                                                                                                                                                                                                                           |
| `dedupeByOcid`                  | boolean       | true                   | One row per procurement (newest notice). Off = every amendment gets its own row                                                                                                                                                                                                                                                      |
| `includeDescription`            | boolean       | true                   | Turn off for smaller rows                                                                                                                                                                                                                                                                                                            |
| `crossSourceDedupe`             | boolean       | true                   | Drop a notice already returned by the source listed first in `sources` (same normalized title + buyer)                                                                                                                                                                                                                               |
| `maxResults`                    | integer       | 200                    | Total saved (charged) rows across sources, split fairly (see below)                                                                                                                                                                                                                                                                  |
| `maxPagesPerSource`             | integer       | 200                    | Safety cap, 100 raw releases per page; FTS ≈ 30 pages/week                                                                                                                                                                                                                                                                           |

Example:

```json
{
    "sources": ["contractsFinder", "findATender"],
    "stage": "tender",
    "publishedFrom": "2026-10-28",
    "publishedTo": "2026-11-04",
    "keywords": ["software", "digital", "cyber", "data"],
    "onlyOpen": true,
    "maxResults": 50
}
```

### Output example (one row; real data from Contracts Finder, Nov 4, 2026)

```json
{
    "source": "Contracts Finder",
    "noticeId": "698da360-f303-47eb-8f37-6e8223279525-920602",
    "ocid": "ocds-b5fd17-7b593149-b063-487f-ac29-45cdba120930",
    "stage": "award",
    "status": "complete",
    "title": "2026-087 AI Technical Sandbox",
    "buyerName": "Ofgem",
    "buyerLocality": "London",
    "publishedDate": "2026-11-04T17:45:14Z",
    "valueAmount": 450000,
    "valueCurrency": "GBP",
    "cpvCode": "72421000",
    "cpvDescription": "Internet or intranet client application development services",
    "category": "services",
    "procurementMethod": "open",
    "suitableForSme": true,
    "contractStart": "2026-10-19T00:00:00+01:00",
    "contractEnd": "2028-12-31T23:59:59Z",
    "awardedSuppliers": ["SoftCat PLC"],
    "awardValue": 450000,
    "noticeUrl": "https://www.contractsfinder.service.gov.uk/Notice/698da360-f303-47eb-8f37-6e8223279525",
    "license": "Open Government Licence v3.0"
}
```

Every row also has `buyerId`, `buyerPostcode`, `regions`, `lastUpdated`, `deadline`, `additionalCpvCodes`, `procurementMethodDetails`, `suitableForVcse`, `lotCount` and (optionally) `description`. A `RUN_SUMMARY` record in the key-value store shows per-source counts, skipped duplicates and why the run stopped.

### How results are counted and ordered

1. Each source is fetched **without** a server-side stage filter, and the **stage filter** is applied here on each release's OCDS tag: `tender` = tender / tenderUpdate / tenderAmendment (plus tenderCancellation only with `includeCancellations: true` or `stage: all`); `award` = award\* / contract\*; `planning` = planning\*. Both portals' own `stages` parameters silently drop notices. Find a Tender's drops Procurement Act 2023 notices (48 of 606 tender-stage releases returned in one week). Contracts Finder's drops planning notices whose status is "planning" (15 of 18). Unless cancellations are included, every release of a procedure (OCID) that has a `tenderCancellation` release is dropped, whichever order the releases arrive in (week of Oct 28–Nov 4: 36 cancelled procedures on Find a Tender, none on Contracts Finder).
2. **Your filters** (keywords, CPV, region, value, open, SME) run next.
3. **Dedupe:** within a source, only the newest *matching* notice per procurement (OCID) is kept (`dedupeByOcid: true`). With `dedupeByOcid: false`, every amendment/update is its own row. Across sources (`crossSourceDedupe`), a row is dropped only if an **earlier** source in `sources` already returned the same normalized title + buyer name. Rows from the same source are never compared with each other. OCIDs differ between the two portals, so title + buyer is the only shared key. In the week of Oct 28–Nov 4, 2026 it matched exactly one procurement (Derby City Council, ENQ1406). It will miss a notice whose title was edited on one portal.
4. **Fair split:** `maxResults` is shared. Each source gets an equal share (e.g. 100 → 50 + 50), and any share one source can't fill goes to the other (e.g. 97 + 3). Rows are saved **interleaved** (CF, FTS, CF, FTS, …), so a spending limit cuts both sources evenly.

### Realistic date ranges

All dates are **UTC calendar days**: `publishedFrom` starts at 00:00:00Z and `publishedTo` ends at 23:59:59Z. `daysBack` counts whole UTC days back from `publishedTo` (default: today in UTC). `daysBack: 0` = only the current UTC day so far, which can be empty early in the UK morning or, from the US, late in the evening. `daysBack: 1` = yesterday + today.

Measured for Oct 28 – Nov 4, 2026 (one week, page size 100; both sources are fetched unfiltered):

| Source           | Raw releases / week | API pages / week | tender (excl. cancellations) | award | planning |
| ---------------- | ------------------- | ---------------- | ---------------------------- | ----- | -------- |
| Find a Tender    | 2,876               | 29               | 567 (+39 with cancellations) | 1,952 | 314      |
| Contracts Finder | 838                 | 9                | 48                           | 772   | 18       |

- Up to **~5 weeks** of both sources fit in the default `maxPagesPerSource: 200` (FTS is about 30 pages a week). For a quarter, set about 400. A full year of FTS (about 1,500 pages) is more than the 1,000-page maximum, so split it into quarterly runs.
- **Run time:** unthrottled, a 1-week run of both sources takes **about 26–35 s** (Polter's QA and my runs, Nov 4, 2026). The slow runs (~150 s or more) are caused by Find a Tender **HTTP 429 rate limiting with `Retry-After: 120`**, not by paging.
- **429 / 5xx handling:** every request is retried up to 7 times (8 attempts). A server `Retry-After` (seconds or HTTP date) is honoured up to 5 minutes. Without one, the Actor backs off exponentially (4 s, 8 s, 16 s … capped at 120 s, +0–20% jitter). Pages are spaced by `requestDelayMs` (500 ms). Counts of 429s and total wait time are in `RUN_SUMMARY.perSource.<source>.http429` / `retryWaitMs`. Expect more 429s on shared cloud IPs. If a source still fails after all retries, the other source's rows are still saved and the error is listed in `RUN_SUMMARY`.
- If a page cap is hit, `RUN_SUMMARY.perSource.<source>.hitPageCap = true` and the log warns that results may be incomplete.
- **Daily schedules: use `daysBack: 1` and dedupe downstream.** Because days are UTC, `daysBack: 0` can miss notices published late in the UTC day, or return nothing when run early in the UTC morning. `daysBack: 1` (yesterday + today) covers that gap but overlaps the previous run, so the same notice can come back (and be charged) twice. Dedupe on `noticeId` (or `ocid` + `lastUpdated`) in your sheet/DB.

### Pricing (pay per event)

| Event                                    | Price                                      |
| ---------------------------------------- | ------------------------------------------ |
| Actor start                              | **$0.005** per run                         |
| Tender notice saved (after your filters) | **$0.004** per notice (= **$4 per 1,000**) |

You pay only for rows that match your filters. Raw notices that are filtered out are free. Each notice is charged just before it is saved. If you set a maximum total charge, the Actor stops cleanly at the limit and never saves an unpaid row. If the limit can't even cover the start event, it exits without fetching. Example: a daily run returning 40 notices costs $0.165.

### Personal data (UK GDPR)

> This Actor drops the OCDS `contactPoint` blocks (named buyer officials, their emails and phones) and replaces email addresses and UK phone numbers found in free text with `[email redacted]` / `[phone redacted]`. It does **not** remove names inside free text. Notice titles and descriptions can still mention individuals (e.g. "representations to the Corporate Director …"), and `awardedSuppliers` can contain the names of sole traders or partnerships, because that is how the government publishes them. If you store or reuse this output, **you are the controller and are responsible for having a lawful basis under UK GDPR / the Data Protection Act 2018** (e.g. legitimate interests for B2B tender research) and for honouring data-subject rights. Don't use it for direct marketing to individuals without checking PECR.

### Limits and notes

- Data is only as complete as the portals. Some notices have no value, region or deadline. Award notices may carry an old tender deadline.
- Find a Tender filters by **last-updated** date, so an older notice amended in your window will appear.
- Phone redaction targets UK formats (+44… or 0 followed by a non-zero digit, 10–11 digits). It skips times, ISO dates and notice reference numbers such as `068147-2026`. Non-UK phone formats in free text are not redacted.
- Placeholder or pointer text in the supplier-name field is removed from `awardedSuppliers`. Examples: "Please refer to …", "See Contracts Finder Notice for full supplier list", "This contract was awarded to 34 suppliers", "Full list of successful suppliers is available on Contracts Finder", "Refer to CAN …", "Multiple Suppliers", "Various", "Not Applicable", "N/A", "TBC", "Withheld Section94 Supplier". In the Oct 28–Nov 4 feeds this removed 17 of 3,333 unique supplier strings. For multi-supplier frameworks, `awardedSuppliers` can therefore be empty; check `noticeUrl`.
- Max 366 days per run; `maxPagesPerSource` default 200, max 1,000 (100 raw releases per page). See [Realistic date ranges](#realistic-date-ranges).
- Dedupe relies on the APIs listing newest releases first (observed, not documented).
- If a run is aborted, rows already collected are charged and saved (fair split, interleaved) before the Actor exits, and `RUN_SUMMARY.stoppedBecause` says so. Rows from pages not yet fetched are lost.
- Not affiliated with the UK Government. Contains public sector information licensed under the [Open Government Licence v3.0](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/).

### FAQ

**Do I need an API key or account on the UK portals?** No.
**Scotland / Wales / NI portals?** Notices above the thresholds appear on Find a Tender. Portal-only notices from Public Contracts Scotland, Sell2Wales and eTendersNI are not included yet.
**Can I get a daily alert?** Yes. Schedule the Actor daily with `publishedFrom`/`publishedTo` empty and `daysBack: 1`, then connect an integration (email, Slack, Sheets). Consecutive runs overlap by one day on purpose (so nothing is missed), so dedupe on `noticeId` downstream.

# Actor input Schema

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

Which official UK portals to query. Contracts Finder = mostly lower-value England notices; Find a Tender = UK-wide higher-value notices.

## `stage` (type: `string`):

tender = open opportunities (tender, tenderUpdate, tenderAmendment; cancellations excluded unless 'Include cancellations' is on); award = award\*/contract\* (winners, updates, terminations); planning = planning/planningUpdate (pipeline); all = every release, including cancellations.

## `includeCancellations` (type: `boolean`):

With stage = tender, also return tenderCancellation releases (FTS: ~37 per week as of Nov 2026). Always included with stage = all.

## `daysBack` (type: `integer`):

Used only when 'From date' is empty. Whole UTC days back from 'To date' (default today, UTC): 0 = only the current UTC day, 1 = yesterday + today, 7 = the last week. Dates are UTC calendar days.

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

YYYY-MM-DD. Leave empty to use Days back. Contracts Finder filters by publish date; Find a Tender by last-updated date.

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

YYYY-MM-DD. Default: today. Max range 366 days.

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

Keep notices whose title or description contains ANY of these words (case-insensitive). Leave empty for all.

## `excludeKeywords` (type: `array`):

Drop notices whose title or description contains any of these words.

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

Keep notices whose main or additional CPV code starts with any of these digits, e.g. 72 (IT services), 45 (construction), 85 (health).

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

Keep notices whose delivery/buyer region contains any of these strings, e.g. 'London', 'West Midlands', or ITL/NUTS codes like 'UKI'.

## `minValue` (type: `integer`):

Minimum estimated (or awarded) value. Notices without a value are dropped when set.

## `maxValue` (type: `integer`):

Maximum estimated (or awarded) value.

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

Keep only notices with a submission deadline later than now.

## `smeSuitableOnly` (type: `boolean`):

Keep only notices the buyer marked as suitable for SMEs (mostly Contracts Finder).

## `dedupeByOcid` (type: `boolean`):

Keep only the newest notice for each procurement (OCID). Turn off to get every amendment/update as its own row.

## `crossSourceDedupe` (type: `boolean`):

Drop a notice when the same procurement (same normalized title + buyer name) was already returned from the source listed first.

## `includeDescription` (type: `boolean`):

Turn off for smaller rows.

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

Total rows saved (and charged) across all sources. Split fairly: each source gets an equal share and any share a source can't fill goes to the other; rows are interleaved CF/FTS.

## `maxPagesPerSource` (type: `integer`):

Safety cap per source; each page is up to 100 raw releases BEFORE stage/filters. Find a Tender is fetched unfiltered (~29 pages per week as of Nov 2026), so for long ranges raise this: ~30 pages per week of FTS. If the cap is hit, RUN_SUMMARY shows hitPageCap=true.

## `requestDelayMs` (type: `integer`):

Be polite to the government APIs.

## Actor input object example

```json
{
  "sources": [
    "contractsFinder",
    "findATender"
  ],
  "stage": "tender",
  "includeCancellations": false,
  "daysBack": 7,
  "keywords": [
    "software",
    "cyber"
  ],
  "onlyOpen": false,
  "smeSuitableOnly": false,
  "dedupeByOcid": true,
  "crossSourceDedupe": true,
  "includeDescription": true,
  "maxResults": 200,
  "maxPagesPerSource": 200,
  "requestDelayMs": 500
}
```

# Actor output Schema

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

No description

## `summary` (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 = {
    "keywords": [
        "software",
        "cyber"
    ]
};

// Run the Actor and wait for it to finish
const run = await client.actor("timeless_enigma/uk-public-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 = { "keywords": [
        "software",
        "cyber",
    ] }

# Run the Actor and wait for it to finish
run = client.actor("timeless_enigma/uk-public-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 '{
  "keywords": [
    "software",
    "cyber"
  ]
}' |
apify call timeless_enigma/uk-public-tenders --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,timeless_enigma/uk-public-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/pWUrgBV1ZhEq05j9v/builds/hWeprYIUxDA6gVpqa/openapi.json
