UK procurement data · OCDS API implementation
The Find a Tender API: collect public OCDS releases reliably.
Use Find a Tender's public OCDS API to collect UK procurement notices with update windows, cursor pagination, lifecycle releases and validation.
A reliable Find a Tender API collector freezes one source-update window, retrieves every OCDS release page through the returned cursor chain, preserves the raw releases, and then refreshes each affected OCID through the record-package endpoint. The public output API is read-only and worked anonymously in bounded checks on August 14, 2026.
The main endpoint is:
GET https://www.find-tender.service.gov.uk/api/1.0/ocdsReleasePackages
It returns at most 100 releases per page. updatedFrom and updatedTo refer to when a source record was last updated, not necessarily its first publication time. Pagination finishes when links.next is absent; the contract publishes no total-count completion rule. (official release-package documentation)
That sounds simple until an edited notice reappears, one release carries several tags, an OCID contains several notices and contracts, a party ID is mistaken for a global supplier identifier, or a cursor run drifts into a moving time window. This guide builds the collector around those failure modes.
Collect changed releases first. Rebuild process state second.
Incremental discovery
Release package
GET /api/1.0/ocdsReleasePackagesChanged publication
releases[]One notice projection with OCID, release ID, date, tags, parties, and the lifecycle sections published in that release.
Durable evidence
raw JSON + SHA-256Retain the actual request URL, package metadata, response bytes, retrieval time, and parser result before normalization.
Affected process
distinct ocidQueue each changed process once for record-package rehydration after the cursor window completes.
Process reconstruction
Record package
GET /api/1.0/ocdsRecordPackages/{ocid}releases[]Source publications- The releases associated with this OCID. Keep them as the evidence behind every derived process state.
compiledReleaseCurrent merged view- A convenient derived snapshot. It is mutable process state, not an immutable event or one signed contract.
versionedReleaseField history- Values annotated with release provenance. Use it to explain change without flattening the process into one row.
The invariant: a compiled view can make current-state queries easier, but it never replaces the releases and retrieval evidence that created it.
Find a Tender API: the short answer
Use this sequence:
- Use the public OCDS output API for published data; keep it separate from the authenticated notice-submission API.
- Write an exact population contract: update window, optional stages, cutoff, endpoint version, and page size.
- Freeze
updatedFromandupdatedTobefore page one. Do not replace the upper bound with “now” on later pages. - Set
limit=100, the documented default and maximum, unless a smaller page is operationally useful. - Persist the actual requested URL, response headers, raw bytes, retrieval time, hash, and returned next link before advancing.
- Treat the cursor as opaque. Follow the same-origin
links.nextchain and stop only when that link is absent. - Honor
Retry-Afteron HTTP 429 and 503, retry the same page, and never advance the watermark after a failed request. - Overlap successive update windows, because boundary timezone and inclusivity are not documented, then deduplicate by
(ocid, release.id)while retaining content hashes. - Preserve OCID, release, notice, lot, award, contract, party, and organisation identifiers at their actual scopes.
- Treat
tagas an array and UK notice type as a separate lifecycle fact. A tender-tagged release is not automatically an open UK4 opportunity. - Fetch
/ocdsRecordPackages/{ocid}for each changed process to retain its releases and inspect compiled and versioned views. - Publish cursor, identity, schema, lifecycle, record-reconciliation, and zero-window evidence with every accepted run.
If the source decision is still Find a Tender versus Contracts Finder, TED, or SAM.gov, begin with the government tender source comparison. If the real requirement is comparable UK data rather than ownership of another connector, inspect the free tenders and RFP edition and the separate EU and UK awards edition.
Use the public output API, not the submission API
Find a Tender exposes public data and accepts notices through different interfaces.
Public OCDS output
The release- and record-package routes publish notice data as Open Contracting Data Standard JSON. They are public read routes. The live bounded requests used for this guide succeeded without credentials.
Use the output API for:
- recurring discovery of newly updated releases;
- targeted retrieval by notice ID or OCID;
- preservation of published planning, tender, award, contract, change, and termination evidence;
- reconstruction of one process through a record package; and
- downstream alerting, lifecycle, supplier, and market-analysis systems.
Authenticated notice submission
Contracting authorities and licensed eSenders use a separate submission workflow. It has credentials, validation, publication, and operational responsibilities that do not apply to a data reuser. Supplier registration, share codes, and bid submission are separate again: Find a Tender publishes the notice, while the buyer can require the bid itself through another portal. (Find a Tender developer documentation; central digital platform factsheet)
Write the source contract before the first call:
OCDS releases returned by Find a Tender for the recorded last-updated window, optional stage filter, page size, and completed cursor chain at the stated retrieval cutoff, supplemented by record-package and XML reconciliations where declared.
That is defensible. “All UK tenders” is not.
Define the UK procurement coverage boundary
The enhanced Find a Tender service launched on February 24, 2025 as the UK central digital platform for Procurement Act 2023 notices. It supports publication throughout the procurement lifecycle, but the legal and territorial boundary still matters. (Cabinet Office factsheet)
The official notice guide says the Procurement Act notice family generally applies to processes beginning on or after that date, with exceptions including Scottish rules, some Northern Irish below-threshold activity, and processes continuing under earlier legislation. Contracts Finder, Public Contracts Scotland, Sell2Wales, eTendersNI, buyer portals, and legacy F-form notices therefore remain relevant according to the research question.
The practical boundary is:
- Find a Tender: the central publication trail for current Procurement Act notices and the continuing FTS history;
- Contracts Finder: material legacy continuity for procurements begun under the earlier English regime;
- devolved and buyer systems: additional coverage, documents, and response workflows; and
- the OCDS API: Find a Tender's public mapping of published notice data, not proof that every purchase or bid opportunity was legally required to appear.
Do not use an API success rate as a legal-market recall rate. The two denominators answer different questions.
Make the first release-package request
This request asks for every release whose source record was last updated inside the recorded August 13 window:
curl --fail-with-body \
--get \
--header 'Accept: application/json' \
--data-urlencode 'updatedFrom=2026-08-13T00:00:00' \
--data-urlencode 'updatedTo=2026-08-13T23:59:59' \
--data-urlencode 'limit=100' \
'https://www.find-tender.service.gov.uk/api/1.0/ocdsReleasePackages'
The documented parameters are:
| Parameter | Contract | Safe use |
| --- | --- | --- |
| limit | Integer from 1 to 100; default 100 | Set it explicitly and record it. |
| cursor | Opaque continuation token, up to 300 characters | Take it from links.next; do not decode, increment, or synthesize it. |
| updatedFrom | Earliest last-updated date and time, YYYY-MM-DDTHH:MM:SS | Freeze the exact string and disclose the operational timezone assumption. |
| updatedTo | Latest last-updated date and time in the same format | Freeze before page one; never let it move during a cursor walk. |
| stages | Comma-separated planning, tender, or award values | Use only as a coarse retrieval filter, not as the full UK notice taxonomy. |
The same release route can append a zero-padded notice ID such as /077448-2026 or an OCID such as /ocds-h6vhtk-06a4dd. A notice lookup returns that publication; an OCID lookup filters the release package to that process. It remains a release-package request, so a long-lived process can still require its links.next cursor chain. Use the separate record endpoint for the documented one-OCID reconstruction view. (release-package request contract)
An observed August 13 update-window run
In an anonymous live probe on August 14, 2026, the exact request above completed in five cursor pages. The hashes identify the responses observed in that run; they are not links to a published immutable artifact, and replaying a last-updated query later can produce different evidence if the source changes.
| Page | Releases | Observed response-body SHA-256 |
| --- | ---: | --- |
| 1 | 100 | ebe6f07e3d310e61b81ec4e02e32c8be8a6af5e6cab97a728d22b366ba0a1435 |
| 2 | 100 | 1c3066be9a84e6bf568d6ea32664b7d6c278e72ce4084c62db7e1dc43a2e38cc |
| 3 | 100 | 1ed8ad249048ba75df0cd49719bc0df6b1427ed2a83d2fb176d1bef1b8bd0f0d |
| 4 | 100 | 45dc33338adc690338a9387a38a99847162bfc375cdc8b15066ba7ce3a22f253 |
| 5 | 54 | 0a4218406ff856c8ca8cb076c64c7edc62ef1915050abcf85af7cdcd7837a008 |
The accepted run contained 454 releases, 420 distinct OCIDs, and 454 distinct (ocid, release.id) pairs. These are releases updated in the queried source window. They are not 454 new procurements, 454 open bids, or a daily UK market total.
The first page began with notice IDs 077448-2026 and 077447-2026. A second page continued with 077446-2026 and 077445-2026, but newest-first ordering is not documented. Do not turn an observed sort order into a cursor contract.
Treat updatedFrom and updatedTo as a source update window
The endpoint documentation describes updatedFrom and updatedTo as the earliest and latest time a record was last updated. That wording has three consequences.
First, an edited notice can reappear in a later window even when the procurement began much earlier. This is desirable for change capture and invalid for counting every returned release as newly published demand.
Second, the request format contains no timezone offset, and the endpoint documentation does not define the timezone or whether either boundary is inclusive. Do not silently label the strings UTC. Choose and document an operating assumption, leave a deliberate overlap, retain the exact strings, and measure duplicate recovery at the boundary.
Third, the response contains several different dates. Package publishedDate, release date, tender periods, award dates, and contract dates are not interchangeable. One live record package differed by one second between package publication time and its latest release. Parse every ISO value as offset-aware where an offset is present, and preserve the source string before converting it.
This matters in PowerShell in particular: ConvertFrom-Json can materialize ISO timestamps as local DateTime values. Use -DateKind String in supported versions or parse deliberately after the raw response is durable. Do not let a workstation timezone rewrite source evidence.
Observed August 13 update window
Frozen query strings; timezone and boundary inclusivity are not stated by the endpoint contract.
2026-08-13T00:00:002026-08-13T23:59:59- Page 1
- Page 2
- Page 3
- Page 4
- Page 5
Cursor values are opaque · no total count is documented · terminal state = no links.next
- 01
Freeze
Record the exact update strings, optional stages, limit, source version, and collection cutoff.
- 02
Fetch
Request page one without a cursor; persist raw bytes and the actual requested URL.
- 03
Checkpoint
Validate the same-origin next link and store it only after the page is durable.
- 04
Continue
Retry the identical page after 429 or 503; never advance the window on failure.
- 05
Finish
Accept the walk when links.next is absent, then queue distinct OCIDs for rehydration.
- 06
Overlap
Start the next window with deliberate overlap and reconcile repeated release keys by content hash.
429 or 503
Honor Retry-After, retain the failed attempt, and repeat the exact request. Do not advance the cursor or watermark.
Next scheduled run
Overlap the prior boundary, then collapse byte-identical repeats only in a derived current view. Quarantine a different hash under the same release key as a source anomaly.
Walk the cursor without moving the window
links.next is the only documented continuation signal. A production walk follows these invariants:
- the first request has frozen filters and no cursor;
- every accepted response belongs to the same endpoint, version, update window, stage filter, and page size;
- the returned next URL is same-origin and same-path before it is used;
- the cursor is opaque and is never decoded for business logic;
- raw page bytes and the actual requested URL are durable before the next cursor is checkpointed;
- a repeated next URL or page hash stops the run as a loop;
- HTTP 429 or 503 waits for
Retry-Afterand retries the identical page; and - the run completes only when
links.nextis absent.
Do not use a short page as the terminal condition. The August run ended with 54 releases, but an exact-multiple result can end on a full page. Do not use the package's top-level uri as the request checkpoint either: in a live second-page response it omitted the cursor even though the actual request included one.
The API documents Retry-After for throttling and service unavailability but publishes no fixed numeric quota. Bounded live work did trigger throttling and a 120-second retry instruction. Treat that as evidence that pacing and resumability are necessary, not as a timeless 12-request entitlement.
Build a restartable Python collector
The following example keeps the window immutable, validates continuation and redirect behavior, handles numeric or HTTP-date Retry-After values, stores raw pages with per-page evidence manifests, resumes from its durable checkpoint, and checkpoints the next cursor only after the current page is durable.
from __future__ import annotations
import hashlib
import json
import random
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import requests
BASE = "https://www.find-tender.service.gov.uk/api/1.0/ocdsReleasePackages"
ALLOWED_HOST = "www.find-tender.service.gov.uk"
RETRYABLE = {429, 503}
def atomic_bytes(path: Path, body: bytes) -> None:
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_bytes(body)
temporary.replace(path)
def atomic_json(path: Path, value: object) -> None:
atomic_bytes(
path,
(json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode(),
)
def retry_delay(value: str | None, attempt: int) -> float:
if value:
try:
return max(0.0, float(value))
except ValueError:
try:
target = parsedate_to_datetime(value)
now = datetime.now(timezone.utc)
return max(0.0, (target - now).total_seconds())
except (TypeError, ValueError, OverflowError):
pass
return min(60.0, 2**attempt) + random.random()
def get_page(session: requests.Session, url: str) -> requests.Response:
for attempt in range(6):
try:
response = session.get(url, timeout=(10, 90), allow_redirects=False)
except (requests.Timeout, requests.ConnectionError):
if attempt == 5:
raise
time.sleep(retry_delay(None, attempt))
continue
if 300 <= response.status_code < 400:
raise RuntimeError(f"unexpected redirect to {response.headers.get('Location')}")
if response.status_code not in RETRYABLE:
response.raise_for_status()
return response
if attempt == 5:
response.raise_for_status()
time.sleep(retry_delay(response.headers.get("Retry-After"), attempt))
raise RuntimeError("retry loop exhausted")
def checked_next_url(next_url: str, frozen: dict[str, str]) -> str:
parsed = urlparse(next_url)
if parsed.scheme != "https" or parsed.netloc != ALLOWED_HOST:
raise ValueError(f"unexpected continuation origin: {next_url}")
if parsed.path != "/api/1.0/ocdsReleasePackages":
raise ValueError(f"unexpected continuation path: {parsed.path}")
query = parse_qs(parsed.query, keep_blank_values=True)
if set(query) != set(frozen) | {"cursor"}:
raise ValueError("continuation changed the request parameter set")
for name, expected in frozen.items():
if query.get(name) != [expected]:
raise ValueError(f"continuation changed {name}")
if len(query.get("cursor", [])) != 1:
raise ValueError("continuation has no single cursor")
return next_url
def load_identities(path: Path) -> dict[tuple[str, str], set[str]]:
if not path.exists():
return {}
rows = json.loads(path.read_text(encoding="utf-8"))
return {
(row["ocid"], row["release_id"]): set(row["hashes"])
for row in rows
}
def identity_rows(identities: dict[tuple[str, str], set[str]]) -> list[dict]:
return [
{"ocid": ocid, "release_id": release_id, "hashes": sorted(hashes)}
for (ocid, release_id), hashes in sorted(identities.items())
]
def completion_summary(
page: int,
identities: dict[tuple[str, str], set[str]],
updated_from: str,
updated_to: str,
stages: str | None,
) -> dict:
return {
"updated_from": updated_from,
"updated_to": updated_to,
"stages": stages,
"pages": page,
"distinct_release_keys": len(identities),
"keys_with_multiple_content_hashes": sum(
len(hashes) > 1 for hashes in identities.values()
),
}
def collect_window(
updated_from: str,
updated_to: str,
output: Path,
stages: str | None = None,
resume_url: str | None = None,
) -> list[dict]:
output.mkdir(parents=True, exist_ok=True)
frozen = {
"updatedFrom": updated_from,
"updatedTo": updated_to,
"limit": "100",
}
if stages:
frozen["stages"] = stages
contract = {"endpoint": BASE, **frozen}
contract_path = output / "contract.json"
if contract_path.exists():
existing = json.loads(contract_path.read_text(encoding="utf-8"))
if existing != contract:
raise ValueError("output directory belongs to another request contract")
else:
atomic_json(contract_path, contract)
session = requests.Session()
session.headers.update({
"Accept": "application/json",
"User-Agent": "your-team-find-tender-collector/1.0",
})
request = requests.Request("GET", BASE, params=frozen).prepare().url
if not request:
raise RuntimeError("could not prepare the first request")
checkpoint_path = output / "checkpoint.json"
completed_path = output / "completed.json"
identity_path = output / "release-index.json"
identities = load_identities(identity_path)
if any(len(hashes) > 1 for hashes in identities.values()):
raise RuntimeError("stored release key has conflicting content hashes")
if completed_path.exists():
return identity_rows(identities)
page = 0
if checkpoint_path.exists():
checkpoint = json.loads(checkpoint_path.read_text(encoding="utf-8"))
saved_next_url = checkpoint.get("next_url")
if resume_url is not None and saved_next_url != resume_url:
raise ValueError("resume URL does not match the durable checkpoint")
page = int(checkpoint["page"])
if saved_next_url:
next_url = checked_next_url(saved_next_url, frozen)
else:
atomic_json(
completed_path,
completion_summary(page, identities, updated_from, updated_to, stages),
)
return identity_rows(identities)
elif resume_url is not None:
raise ValueError("resume URL supplied without a durable checkpoint")
else:
next_url = request
seen_next_urls: set[str] = set()
page_hashes: dict[str, int] = {}
for manifest_path in sorted(output.glob("page-*.meta.json")):
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
prior_page = int(manifest["page"])
prior_hash = manifest["page_sha256"]
if prior_hash in page_hashes and page_hashes[prior_hash] != prior_page:
raise RuntimeError("stored page hash occurs at multiple cursor positions")
page_hashes[prior_hash] = prior_page
while next_url:
if next_url in seen_next_urls:
raise RuntimeError("cursor loop detected")
seen_next_urls.add(next_url)
page += 1
requested_url = next_url
response = get_page(session, requested_url)
if response.url != requested_url:
raise RuntimeError("HTTP client changed the requested URL")
if "application/json" not in response.headers.get("Content-Type", ""):
raise ValueError("unexpected response content type")
body = response.content
payload = response.json()
releases = payload.get("releases")
if not isinstance(releases, list):
raise ValueError("release package has no releases array")
raw_hash = hashlib.sha256(body).hexdigest()
prior_page = page_hashes.get(raw_hash)
if prior_page is not None and prior_page != page:
raise RuntimeError(f"page body repeats cursor position {prior_page}")
for release in releases:
ocid, release_id = release.get("ocid"), release.get("id")
if not ocid or not release_id:
raise ValueError("release missing ocid or id")
canonical = json.dumps(
release, sort_keys=True, separators=(",", ":"), ensure_ascii=False
).encode()
content_hash = hashlib.sha256(canonical).hexdigest()
known_hashes = identities.setdefault((ocid, release_id), set())
if known_hashes and content_hash not in known_hashes:
raise RuntimeError("immutable release key returned changed content")
known_hashes.add(content_hash)
candidate = (payload.get("links") or {}).get("next")
next_url = checked_next_url(candidate, frozen) if candidate else None
retrieved_at = datetime.now(timezone.utc).isoformat()
raw_path = output / f"page-{page:04d}.json"
manifest_path = output / f"page-{page:04d}.meta.json"
page_manifest = {
"page": page,
"request_url": requested_url,
"response_url": response.url,
"status": response.status_code,
"response_headers": dict(response.headers.items()),
"retrieved_at": retrieved_at,
"page_sha256": raw_hash,
"response_bytes": len(body),
"release_count": len(releases),
"next_url": next_url,
}
if raw_path.exists() and hashlib.sha256(raw_path.read_bytes()).hexdigest() != raw_hash:
raise RuntimeError("stored page conflicts with replayed response")
if manifest_path.exists():
existing_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
for field in ("page", "request_url", "page_sha256", "release_count", "next_url"):
if existing_manifest.get(field) != page_manifest[field]:
raise RuntimeError(f"stored page manifest changed at {field}")
else:
atomic_bytes(raw_path, body)
atomic_json(manifest_path, page_manifest)
page_hashes[raw_hash] = page
atomic_json(identity_path, identity_rows(identities))
atomic_json(checkpoint_path, {
"updated_from": updated_from,
"updated_to": updated_to,
"stages": stages,
"page": page,
"request_url": requested_url,
"page_sha256": raw_hash,
"release_count": len(releases),
"next_url": next_url,
"retrieved_at": retrieved_at,
})
atomic_json(
completed_path,
completion_summary(page, identities, updated_from, updated_to, stages),
)
return identity_rows(identities)
if __name__ == "__main__":
rows = collect_window(
"2026-08-13T00:00:00",
"2026-08-13T23:59:59",
Path("fts-2026-08-13"),
)
print(f"accepted {len(rows)} distinct release keys")
The example deliberately preserves raw pages and per-page manifests rather than pretending a local upsert is the audit trail. Rerunning the command resumes automatically from an incomplete checkpoint and returns immediately after a completed run. In production, write raw page storage, release rows, and the outgoing checkpoint in one durable transaction or an equivalent idempotent sequence. If the process dies after page storage but before checkpointing, replaying the page should produce the same (ocid, release.id, content_hash) evidence; a conflicting hash is quarantined.
Use a protected checkpoint store if cursor URLs are operationally sensitive. Never emit a cursor into public analytics, and never log buyer-portal credentials alongside a public-output collector.
Preserve the release-package contract
The top-level response is an OCDS release package, not a bare array. Retain:
uri, while separately retaining the actual request URL;versionand everyextensionsURL;publishedDateas package metadata, not notice publication time;publisher,license, andpublicationPolicy;releases[]; andlinks, includingnextwhen present.
Find a Tender says its fields are mapped to OCDS 1.1.5 with extensions. A live package reported version: "1.1", as OCDS packages do at the major/minor family level, plus extension URLs. Snapshot the exact core schema and extensions used by the validator: live extension references can contain moving labels such as latest, master, or main. Do not let tomorrow's remote schema silently redefine yesterday's accepted payload. (Find a Tender data outputs; OCDS release-package schema)
Find a Tender's valid no-result envelope needs an explicit source exception. A bounded empty-window probe returned HTTP 200 with releases: [], an empty publishedDate, and no continuation. That is operationally a successful zero, but it is not strictly valid against the OCDS 1.1 release-package requirements for a date-time publishedDate and at least one release. Accept it only after the transport, request-window, JSON-container, and no-next checks pass; emit a schema-conformance warning instead of calling it schema-valid or coercing other failures to zero.
Inside each release, expect lifecycle-specific shapes:
ocid,id,date,tag[],initiationType, andlanguage;parties[]plus buyer, procuring entity, supplier, tenderer, or other roles;planning,tender,awards[], andcontracts[]according to the notice;- lots, items, values, periods, milestones, documents, and addresses at their defined grains; and
relatedProcessesand extension fields where the source publishes them.
Optional sections can be absent. An award release need not contain a bid deadline, and a UK4 tender normally should not name the eventual supplier. Schema presence must be scored against the applicable lifecycle stage, not all releases mixed together.
Keep every identifier at its real scope
Contracting process
ocidsource + ocid
Globally unique OCDS process identifier. One process can still contain many notices, lots, awards, and contracts.
Unsafe shortcut: Do not read one OCID as one signed contract.
Release publication
release.idsource + ocid + release.id
Release ID is documented as unique only inside its OCID. Add content hash and retrieval time for observations.
Unsafe shortcut: Release ID alone is not a global key.
Award
award.idsource + ocid + award.id
Award identifiers are process-scoped and can connect supplier and award facts across releases.
Unsafe shortcut: Do not merge awards from different OCIDs by ID.
Contract
contract.id / awardIDsource + ocid + contract.id
Contract ID is process-scoped; contract.awardID links to the related award within the process.
Unsafe shortcut: Contract ID or value alone does not prove a unique award.
Party occurrence
parties[].idsource + ocid + release.id + party.id
Find a Tender documents the party ID as unique only inside one release.
Unsafe shortcut: Never promote party.id or a name to a global supplier key.
Nested array member
items / documents / milestonessource + ocid + release.id + container path + owner ID + child ID
These IDs are unique only inside their containing array; retain the source path and immediate tender, award, contract, or lot owner.
Unsafe shortcut: The same child ID in another array does not establish a relationship.
For cross-release organisation resolution, preserve reported identifier.scheme + identifier.id pairs and their provenance. Presence is evidence for a candidate entity key, not permission to discard the source occurrence.
Use these storage keys:
process = source + ocid
release publication = source + ocid + release.id
release observation = source + ocid + release.id + content_hash + retrieved_at
award = source + ocid + award.id
contract = source + ocid + contract.id
party occurrence = source + ocid + release.id + party.id
nested array member = source + ocid + release.id + container_path + owner_id + child.id
The endpoint documentation says release IDs are unique within an OCID and party IDs only within one release. OCDS also scopes award and contract IDs to a contracting process. A contract.awardID links the contract to its award within that OCID. (Find a Tender identifier statement; OCDS identifier guidance)
For cross-release organisation work, prefer a published identifier.scheme plus identifier.id, such as a Companies House or PPON identifier, when it is present and semantically appropriate. Preserve all identifiers and provenance. Do not turn a release-local party ID, buyer name, or supplier name into a global organisation key.
OCDS releases are immutable: a normal notice edit should publish a new release ID under the same OCID. Keep every release and derive a current pointer separately. A byte-identical (ocid, release.id) repeated across overlapping windows is expected duplicate evidence; a different canonical hash under the same pair is a source correction, serialization drift, or nonconformance to quarantine and investigate—not the normal edit model. (OCDS release reference)
Separate OCDS tags from UK notice types
Representative contract path
UK1–UK3Early signals
Pipeline, market engagement, and planned procurement
UK4Competition
Tender or request to participate
UK6Award decision
Decision published before contract signature
UK7Signed contract
Contract details after the contract is entered into
UK9–UK11Post-signature record
Performance, change, completion, or termination
Direct-award branch
UK5 transparency
Published before a direct award; it is not an open competitive tender.
Pre-signature stop
UK12 procurement termination
Records that a procurement will not result in a signed contract.
Adjacent lifecycle
UK13–UK16
Dynamic-market establishment, modification, and termination notices need their own relationship rules.
Authority reporting
UK17 payments compliance
Six-month statistics belong to a contracting authority and reporting period, not one procurement process.
- Publication grain
- One release represents a publication view, not necessarily one tender, lot, award, or contract.
- Process grain
- One OCID can carry several releases and several child commercial objects.
- Filter grain
- stages=planning,tender,award is a coarse API filter, not the complete UK1–UK17 taxonomy.
Find a Tender's stages request parameter accepts only planning, tender, and award. The returned tag field is an array, and the legal notice family is richer.
In the observed August 13 update-window run, tag occurrences included 303 award, 303 contract, 66 tender, 31 planning, 28 tenderUpdate, 12 awardUpdate, 12 contractUpdate, five tenderCancellation, two contractAmendment, and one contractTermination. Those counts deliberately exceed 454 because tags can overlap; many releases carried both award and contract facts.
The operational reading is:
- UK1–UK3 are pipeline, engagement, and planned-procurement signals;
- UK4 advertises tenders or requests to participate in a competitive procedure;
- UK5 gives transparency before a direct award;
- UK6 follows the award decision but precedes contract signature;
- UK7 reports the contract after it is entered into;
- UK9–UK11 cover post-signature performance, change, completion, or termination; and
- UK12 stops a procurement before signature.
UK13–UK16 add dynamic-market branches. UK17 is different: it reports six-month payment-compliance statistics for a contracting authority and reporting period, not one procurement process or contract. Earlier F-form notices also remain in Find a Tender. Never infer the exact UK notice type only from a generic OCDS tag, never equate stages=tender with UK4, and never count UK6 and UK7 as two signed contracts. (Find a Tender notice types and sequences)
An “open opportunity” view needs at least the notice type, tender status, relevant lot or tender deadline, cancellation and update evidence, procedure rules, and an authoritative response route. Framework call-offs can have no public UK4 because only framework members compete.
Rehydrate changed processes with record packages
The record endpoint is:
GET https://www.find-tender.service.gov.uk/api/1.0/ocdsRecordPackages/{ocid}
It accepts one OCID in the path and returns a record package. The documented response contains:
records[].releases[]: source releases associated with the contracting process;records[].compiledRelease: a derived merged view of the latest known process state; andrecords[].versionedRelease: field-level history with release provenance.
The endpoint does not document a paginated all-records listing. Discover changed OCIDs from the release feed, deduplicate them, then retrieve individual record packages at a bounded rate. A 404 means that OCID was not found at the time of the request; classify it, retry where appropriate, and do not convert it to an empty process. (record-package contract; OCDS record-package schema)
For OCID ocds-h6vhtk-06a4dd, a bounded August 14 check returned three releases plus compiled and versioned views. Its observed response-body SHA-256 was:
afbc14568afc11838f3ba7769cfccc11f7daba7a260363a28981fa3b134ef5d6
Use compiledRelease for a convenient current process view, not as a replacement for the releases that produced it. Use versionedRelease to explain which release changed a field, not as a flat transactional fact table. Preserve your own raw observations as well: the service's current record response is not a guarantee that every byte ever returned by earlier API calls will remain reconstructible.
Normalize without erasing OCDS provenance
Keep an immutable source layer and derive analytical tables from it.
Raw release observation
Store the request URL, frozen window, actual cursor URL, retrieval time, response headers, package metadata, raw page hash, raw release JSON, canonical release hash, parser version, schema bundle hash, and validation result.
Process and publication tables
Create a process table keyed by OCID and a release table keyed by OCID plus release ID. Keep release date, all tags, notice type, source URL, content hash, first seen, last seen, and current-version evidence separate.
Relational child grains
Explode lots, parties and roles, organisation identifiers, items and classifications, documents, awards and suppliers, contracts and awardID, milestones, values, periods, and related processes into tables at their declared scopes. Awards and contracts use OCID plus their source ID. Items, documents, and milestones require the containing array or source path plus the immediate tender, lot, award, or contract owner: the same child ID in another array does not establish a relationship.
Do not flatten all money into one value column. Tender estimates, lot estimates, award values, and contract values answer different questions. Keep amount, currency, VAT basis when published, value basis, object grain, and source path. Do not add an award value to its related contract value as if they were separate public spending.
Do not flatten all dates into one deadline. Tender submission, participation request, award decision, contract signature, performance period, notice release, package publication, source update window, and collector retrieval are different clocks.
The government contracts database guide develops the wider opportunity-to-award-to-supplier model. The procurement data-quality benchmark shows why stage-conditioned denominators and source-mapping checks need to travel with the normalized result.
Use daily XML as a second evidence lane
Find a Tender's developer page says the same notice data is available through daily ZIP files on data.gov.uk, with one XML file per notice. Use the OCDS API for incremental, process-aware JSON collection and the daily archives as a separately inventoried publication-day baseline. (official data and API documentation)
A defensible reconciliation records:
- the data.gov.uk package URL and dataset revision used;
- archive retrieval time, headers, byte count, and SHA-256;
- member filename, size, hash, XML namespace, schema family, and parse status;
- notice identifiers derived independently from JSON and XML;
- set differences at an explicit cutoff; and
- whether a difference is an update-window edit, publication-day member, schema failure, late availability, or unexplained exception.
Do not expect a source-update window to equal a publication-day archive by default. The API window can include later edits to older notices. Align the populations before comparing counts, and preserve the old F-form and Procurement Act parsing paths that actually occur in the archive.
Validate every Find a Tender run
| Gate | Acceptance evidence | Stop condition |
|---|---|---|
| 01Request contract | Actual URL, immutable filters, retrieval time, response status and headers | Reject: A filter, endpoint, method, or time assumption is missing or changes mid-run |
| 02Cursor chain | Unique next URLs and page hashes; terminal response has no links.next | Reject: Cursor loop, changed filter, off-origin link, or page stored after checkpoint |
| 03Package contract | Publisher, license, publication policy, version, extensions and package date | Reject: Unexpected container, content type, or missing provenance metadata |
| 04Schema | Pinned core and extension schemas; unknown fields retained and profiled | Reject: Required structure fails or a remote moving schema changes acceptance silently |
| 05Release identity | Unique (ocid, release.id) pairs plus canonical hashes across overlaps | Reject: Missing key or a different hash under an immutable release key is not quarantined |
| 06Record reconciliation | Changed OCIDs have a classified record lookup; sampled release IDs reconcile | Reject: Feed release disappears from the record view without a documented exception |
| 07Merge provenance | Compiled and versioned values can be traced to source releases | Reject: Derived current values cannot be explained from retained evidence |
| 08Lifecycle grain | Notice, process, lot, award and contract counts remain separate | Reject: UK6 is counted as signed, tags are collapsed, or tender is equated with bid-ready |
| 09Recovery | 429/503 waits for Retry-After and repeats the identical request | Reject: Watermark or cursor advances after a failed or unstored page |
| 10Successful zero | HTTP, JSON container, frozen window, absent continuation and an explicit empty-envelope conformance warning | Reject: Auth, HTML, parse, rate-limit, or upstream errors are coerced to zero |
The run should fail closed when its evidence cannot support the declared population.
Request and transport
- method, endpoint version, actual URL, exact update strings, optional stage filter, limit, response status, content type, retrieval time, and elapsed time are retained;
- unknown request parameters produce a classified 400, not an empty dataset;
- 429 and 503 honor
Retry-After, including an HTTP-date value if one is returned; and - transport retries repeat the same page and never move the watermark.
Cursor chain
- every next URL is HTTPS on the expected Find a Tender host and release-package path;
- frozen filters are identical on every page;
- every nonterminal response has one cursor and no next URL or page hash repeats;
- each page is durable before its outgoing cursor checkpoint; and
- completion is the absence of
links.next.
Package and schema
- publisher, license, publication policy, package version, extension URLs, and package publication time are retained;
- the core and extension schemas used by validation are snapshotted or content-addressed;
- unknown properties are preserved in raw JSON and profiled rather than silently discarded; and
- a successful zero window matches Find a Tender's empty-envelope behavior, has no continuation, and carries an explicit OCDS-conformance warning rather than a claim of strict schema validity.
Identity and lifecycle
- every release has OCID and release ID, and accepted pairs are unique within a page chain;
- byte-identical pairs across overlapping runs reconcile as repeats, while a different hash under one immutable release key is quarantined;
- party IDs remain release-scoped, award and contract IDs remain OCID-scoped, and nested array IDs retain their container path and immediate owner;
- tags remain arrays, notice type remains separately available, and source links are retained; and
- opportunity, award-decision, signed-contract, change, and termination outputs use distinct acceptance rules.
Record reconciliation
- every changed OCID has a record retrieval state;
- sampled feed release IDs appear in the matching record package or have a documented exception;
- compiled fields can be traced through releases or versioned field evidence; and
- record-package hashes and refresh cutoffs are retained separately from release-page hashes.
The government contract tracker guide shows how to turn those source checks into recurring alerts without confusing a source edit with a new procurement.
Choose direct API access or normalized delivery
Build directly on Find a Tender
Choose direct OCDS collection when the product needs source-native extensions, exact update windows, record reconstruction, all notice families, or deep UK-only fields. Budget for cursor recovery, archive storage, moving extension references, stage-aware mapping, daily XML reconciliation, and legacy notice formats.
Use the public normalized editions
Choose the government tenders and RFP dataset for comparable stable records, observed history, changes, governance, and source provenance across SAM.gov, TED, Find a Tender, and Contracts Finder. Its current-opportunity view is a stricter derived product whose source mappings can differ by edition; inspect the manifest and field-completeness evidence before treating it as Find a Tender opportunity coverage. Choose the EU and UK contract-awards dataset for award, supplier, and contract grains with aggregation-safe analytical outputs.
Use the Python and DuckDB release guide to verify a tagged normalized edition, select the correct artifact grain, reproduce source counts, and export a bounded result with input hashes.
Those editions are bounded public products, not full mirrors of every Find a Tender field or the complete UK legal market.
Request scoped managed delivery
Use a scoped feed when the requirement adds exact UK notice families, devolved or buyer sources, longer history, document capture, qualification rules, opportunity-to-award linking, enrichment, or scheduled delivery into a database, bucket, CRM, or internal API contract. This is a managed-delivery option; no public WebTruffle API is being promised.
Find a Tender API production checklist
- [ ] Confirm the public OCDS output API is the correct service, not the submission or supplier workflow.
- [ ] Define jurisdiction, notice families, stages, time semantics, and exclusions.
- [ ] Freeze exact
updatedFrom,updatedTo, optionalstages, andlimit=100before page one. - [ ] State the timezone assumption and overlap successive update windows.
- [ ] Treat every cursor as opaque and validate the returned next URL before use.
- [ ] Persist raw bytes and the actual request URL before checkpointing the next cursor.
- [ ] Stop only when
links.nextis absent; do not depend on page length or a total count. - [ ] Honor
Retry-Afteron 429 and 503 and retry the identical page. - [ ] Preserve package version, extensions, publisher, license, publication policy, and source timestamps.
- [ ] Key releases by OCID plus release ID; quarantine a different hash under the same immutable key.
- [ ] Keep party IDs release-scoped and child award and contract IDs OCID-scoped.
- [ ] Preserve every tag and the exact UK or legacy notice type separately.
- [ ] Do not call every tender-tagged release bid-ready or every OCID one contract.
- [ ] Rehydrate every changed OCID through the record-package endpoint.
- [ ] Keep immutable releases beside compiled and versioned process views.
- [ ] Parse timestamps as offset-aware values without rewriting the raw source strings.
- [ ] Normalize money and dates at their actual tender, lot, award, and contract grains.
- [ ] Reconcile declared populations to daily XML packages where the product requires it.
- [ ] Accept a zero only after transport, JSON-envelope, cursor, and source-window checks pass, then emit the empty-envelope schema warning.
- [ ] Publish page, schema, identity, lifecycle, record, and reconciliation evidence.
Frequently asked questions
Does the Find a Tender OCDS API require an API key?
The public release- and record-package output routes worked anonymously in bounded August 14, 2026 checks. The authenticated notice-submission API is a different workflow. Do not add submission credentials to a read-only reuser collector.
What is the Find a Tender release-package endpoint?
Send an HTTP GET to https://www.find-tender.service.gov.uk/api/1.0/ocdsReleasePackages. Add updatedFrom, updatedTo, optional stages, limit, and the returned cursor as documented. Append a notice ID or OCID to the path for a targeted release-package lookup.
Are updatedFrom and updatedTo publication-date filters?
The endpoint contract calls them the earliest and latest times the record was last updated. An edit to an older notice can therefore appear in a later window. The documentation does not state timezone or boundary inclusivity, so retain the exact strings, use overlap, and deduplicate by source identity and content hash.
How do I know cursor pagination is complete?
Continue while the response contains links.next; finish when it is absent. There is no documented total-count contract, and a short page is not the authoritative stop condition. Preserve the actual request URL and detect repeated continuation URLs or page hashes.
What is the difference between a release package and a record package?
A release package is the incremental publication feed: it returns releases and supports update windows and cursors. A record package retrieves one OCID and provides the process's releases plus compiled and versioned views. Use both, while keeping the raw releases as evidence.
Is an OCID one tender or one contract?
No. An OCID identifies one contracting process. That process can have several notices, versions, lots, awards, and contracts. One release is one publication representation, and one release can also contain several child objects.
Can I use a party ID as a supplier identifier?
Not across releases. Find a Tender documents party IDs as unique only inside one release. Use published organisation identifier schemes and values for cross-release entity work when available, retain every source occurrence, and keep any probabilistic name matching explicit.
Does stages=tender return only open UK4 opportunities?
No. stages is a coarse OCDS retrieval filter, while Find a Tender publishes a larger UK notice taxonomy and legacy F-forms. A bid-ready view needs notice type, status, deadline, cancellation or update evidence, procedure rules, documents, and the authoritative response route.
How should a collector handle edited and cancelled notices?
Use last-updated windows with overlap, preserve each immutable release ID and every update or cancellation tag, and refresh the affected record package. Normal edits create new releases under the same OCID. Treat an identical pair and hash as a repeated observation; quarantine a different hash under the same (ocid, release.id) instead of accepting it as a normal edit. Never infer cancellation from disappearance alone.
What should happen after HTTP 429 or 503?
Honor the response's Retry-After value, add bounded retry handling, and repeat the exact same request. Do not advance the cursor or update watermark until the page is successfully stored and validated. The documentation publishes retry behavior, not a fixed numeric request quota.
Does Find a Tender cover every UK public procurement?
No. It is the central platform for current Procurement Act publication, subject to legal, territorial, threshold, framework, and legacy boundaries. Contracts Finder, devolved portals, buyer systems, and older notice regimes remain relevant to broader coverage.
Can Find a Tender notice data be reused commercially?
The developer documentation publishes the notice output under the Open Government Licence. Retain the package license and publication policy, attribute the source as required, and separately assess third-party documents or linked buyer content rather than assuming every attachment has identical rights.
Does WebTruffle provide a public Find a Tender API?
No public WebTruffle API is promised here. The public products are downloadable normalized editions. Managed work can add a scoped API-shaped delivery contract when the source, history, fields, validation, cadence, and operational responsibility are agreed.