If you track UK public sector contracts, for sales leads, market research or journalism, you probably noticed it got messier. Since the Procurement Act 2023 regime started in 2025, notices are spread across two government systems:
- Find a Tender Service (FTS): the central platform, used for most notices under the new regime
- Contracts Finder (CF): still live and still receiving notices, including lower-value opportunities If you only watch one, you miss part of the market. In a 3-day sample I pulled this week, FTS had 1,247 notices and Contracts Finder had 497.
The good news: both publish open data in the same standard (OCDS), with no API key, under the Open Government Licence v3, which allows commercial reuse with attribution.
The bad news: "same standard" doesn't mean "same data". Here's what I ran into while merging them into one feed.
Disclosure: I turned this into a paid Apify actor (linked at the end). Everything below also applies if you build it yourself, and I've tried to write it so you can.
1. The rate limit is stricter than the docs suggest
Both hosts started returning HTTP 429 with Retry-After: 120 after roughly 20 requests in my tests. Contracts Finder's docs mention a 403 instead, but I got 429s. After the first burst I could get about 5 pages a minute.
This was consistent across three test runs, but it's my observation, not a documented number. In practice:
- Respect
Retry-After. Don't retry blindly. - Keep date windows small. A 1-day pull finishes in seconds; a 7-day pull of everything takes minutes.
- For monitoring, run daily and only fetch what's new since the last run.
## 2. The
stagesfilter silently drops notices
Both APIs accept a stage filter (tender, award, ...). It looks like the obvious way to get only open tenders.
Don't use it. In one test, FTS with stages=tender returned 11 of 101 tender releases that were in the unfiltered response. No error, just missing rows.
Fetch everything for the date window and filter on your side using the release's tag and tender.status.
3. The same tender can appear in both systems with different IDs
OCDS gives every procurement an ocid. You'd expect to deduplicate on it. But the two systems use different ocid prefixes, so the same tender published in both places looks like two different procurements.
Real example from this week: a council RFQ for a "Dublin Belfast Economic Corridor Investment Land Guide" appeared in both feeds with different ocids.
What worked for me was a composite key:
import re
PREFIX = re.compile(r"^(GB-[A-Za-z]+:|UK\d+ Tender Notice:|[A-Z]{2,4}_?\d+\s*-)\s*", re.I)
def norm(s: str) -> str:
s = PREFIX.sub("", s or "")
return re.sub(r"[^a-z0-9 ]", "", s.lower()).strip()
def dedup_key(n: dict) -> tuple:
return (
norm(n["buyer_name"]),
norm(n["title"]),
(n.get("deadline_date") or "")[:10], # same day
)
Titles often carry system prefixes ("GB-Glasgow:", "UK4 Tender Notice:", "PRJ_31142 -"), so strip those first.
There's a second, smaller case: the same system sometimes publishes a notice twice a few minutes apart, with identical title and text but different IDs. I merge those too and keep the other ID in a related_notice_ids field, so nothing is lost.
4. Fields that look the same but aren't
A few things that will bite you if you just map field names:
-
Region formats differ. FTS uses NUTS codes (
UKK14); Contracts Finder often uses names ("East of England"). And about half of CF notices have no region at all. I split it intoregion_code+region_nameand map CF names to NUTS1. -
Award notices carry old deadlines. A CF award notice can have a
deadline_datefrom 2024, which is the original tender deadline. If you show it as "deadline", users will think it expired. For awards, move it to a separate field. - CF award value ≠ awarded amount. It's often the tender's estimated value.
- Some FTS notices have no CPV code, so a CPV filter won't see them. Add a keyword fallback if you need full coverage.
- CF descriptions sometimes repeat themselves. The "Additional information" block can be a full copy of the description. ## Doing it yourself
Here's a minimal Contracts Finder pull for the last 24 hours:
import time, requests
from datetime import datetime, timedelta, timezone
BASE = "https://www.contractsfinder.service.gov.uk/Published/Notices/OCDS/Search"
now = datetime.now(timezone.utc)
params = {
"publishedFrom": (now - timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
"publishedTo": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"limit": 100,
}
url, releases = BASE, []
while url:
r = requests.get(url, params=params, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 120)))
continue
r.raise_for_status()
data = r.json()
releases += data.get("releases", [])
url, params = data.get("links", {}).get("next"), None
print(len(releases), "releases")
Find a Tender has its own OCDS endpoint (/api/1.0/ocdsReleasePackages) with similar pagination. Check the FTS API docs for the current parameters. Then normalise both into one schema and apply the dedup above.
Or use the hosted version
If you'd rather not maintain this, I published it as an Apify actor: UK Public Tenders: Find a Tender + Contracts Finder.
- One schema for both systems, with cross-source and same-source dedup
- Filters: keywords, CPV prefix (e.g.
72for IT), buyer, value range, region, notice type -
onlyNewmode for daily monitoring: schedule it and get only notices you haven't seen - Handles the rate limit and keeps going if one source is down (logged in a run summary)
- Open tenders and planning notices by default; awards are opt-in
- Pricing: $3 per 1,000 results (less on higher Apify plans)
from apify_client import ApifyClient
client = ApifyClient("YOUR_APIFY_TOKEN")
run = client.actor("seralifatih/uk-public-tenders").call(run_input={
"cpvCodes": ["72"], # IT services
"minValue": 100000,
"onlyNew": True,
})
for n in client.dataset(run["defaultDatasetId"]).iterate_items():
print(n["published_date"][:10], n["buyer_name"], "|", n["title_clean"], "|", n["deadline_date"])
Data: Contains public sector information licensed under the Open Government Licence v3.0.
If you track UK procurement another way, or hit a quirk I didn't list, tell me in the comments.
Top comments (1)
The composite key is a solid fix. One thing that would make it sturdier: buyer names usually differ between systems more than titles do ("X Borough Council", "X Council", the legal name). If the party in the notice carries an identifier, like a company or charity number, I'd key on that and keep the normalised name as the fallback. A buyer is a record, not a string.