Skip to article

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.

Published August 13, 202625 min readBy DanielReviewed by Alexandra

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.

Release feed → process dossier

Collect changed releases first. Rebuild process state second.

Incremental discovery

Release package

GET /api/1.0/ocdsReleasePackages
  1. Changed publication

    releases[]

    One notice projection with OCID, release ID, date, tags, parties, and the lifecycle sections published in that release.

  2. Durable evidence

    raw JSON + SHA-256

    Retain the actual request URL, package metadata, response bytes, retrieval time, and parser result before normalization.

  3. Affected process

    distinct ocid

    Queue 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:

  1. Use the public OCDS output API for published data; keep it separate from the authenticated notice-submission API.
  2. Write an exact population contract: update window, optional stages, cutoff, endpoint version, and page size.
  3. Freeze updatedFrom and updatedTo before page one. Do not replace the upper bound with “now” on later pages.
  4. Set limit=100, the documented default and maximum, unless a smaller page is operationally useful.
  5. Persist the actual requested URL, response headers, raw bytes, retrieval time, hash, and returned next link before advancing.
  6. Treat the cursor as opaque. Follow the same-origin links.next chain and stop only when that link is absent.
  7. Honor Retry-After on HTTP 429 and 503, retry the same page, and never advance the watermark after a failed request.
  8. Overlap successive update windows, because boundary timezone and inclusivity are not documented, then deduplicate by (ocid, release.id) while retaining content hashes.
  9. Preserve OCID, release, notice, lot, award, contract, party, and organisation identifiers at their actual scopes.
  10. Treat tag as an array and UK notice type as a separate lifecycle fact. A tender-tagged release is not automatically an open UK4 opportunity.
  11. Fetch /ocdsRecordPackages/{ocid} for each changed process to retain its releases and inspect compiled and versioned views.
  12. 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.

One frozen window, one cursor chainThe window does not move while pages are in flight.

Observed August 13 update window

Frozen query strings; timezone and boundary inclusivity are not stated by the endpoint contract.

454 releases · 420 OCIDs
2026-08-13T00:00:002026-08-13T23:59:59
  1. Page 1
  2. Page 2
  3. Page 3
  4. Page 4
  5. Page 5

Cursor values are opaque · no total count is documented · terminal state = no links.next

  1. 01

    Freeze

    Record the exact update strings, optional stages, limit, source version, and collection cutoff.

  2. 02

    Fetch

    Request page one without a cursor; persist raw bytes and the actual requested URL.

  3. 03

    Checkpoint

    Validate the same-origin next link and store it only after the page is durable.

  4. 04

    Continue

    Retry the identical page after 429 or 503; never advance the window on failure.

  5. 05

    Finish

    Accept the walk when links.next is absent, then queue distinct OCIDs for rehydration.

  6. 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-After and retries the identical page; and
  • the run completes only when links.next is 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;
  • version and every extensions URL;
  • publishedDate as package metadata, not notice publication time;
  • publisher, license, and publicationPolicy;
  • releases[]; and
  • links, including next when 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, and language;
  • parties[] plus buyer, procuring entity, supplier, tenderer, or other roles;
  • planning, tender, awards[], and contracts[] according to the notice;
  • lots, items, values, periods, milestones, documents, and addresses at their defined grains; and
  • relatedProcesses and 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

Nested identifier scopesEvery child key inherits its container and owner boundary.
  1. Contracting process

    ocid

    source + 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.

  2. Release publication

    release.id

    source + 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.

  3. Award

    award.id

    source + 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.

  4. Contract

    contract.id / awardID

    source + 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.

  5. Party occurrence

    parties[].id

    source + 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.

  6. Nested array member

    items / documents / milestones

    source + 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

Notice lifecycle ≠ one linear contract rowKeep decision, signature, change, and termination distinct.

Representative contract path

  1. UK1–UK3

    Early signals

    Pipeline, market engagement, and planned procurement

  2. UK4

    Competition

    Tender or request to participate

  3. UK6

    Award decision

    Decision published before contract signature

  4. UK7

    Signed contract

    Contract details after the contract is entered into

  5. UK9–UK11

    Post-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; and
  • records[].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:

  1. the data.gov.uk package URL and dataset revision used;
  2. archive retrieval time, headers, byte count, and SHA-256;
  3. member filename, size, hash, XML namespace, schema family, and parse status;
  4. notice identifiers derived independently from JSON and XML;
  5. set differences at an explicit cutoff; and
  6. 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

Find a Tender acceptance ledgerA completed request is not yet an accepted dataset.
Validation gates, required acceptance evidence, and stop conditions for a Find a Tender OCDS collector
GateAcceptance evidenceStop condition
01Request contractActual URL, immutable filters, retrieval time, response status and headersReject: A filter, endpoint, method, or time assumption is missing or changes mid-run
02Cursor chainUnique next URLs and page hashes; terminal response has no links.nextReject: Cursor loop, changed filter, off-origin link, or page stored after checkpoint
03Package contractPublisher, license, publication policy, version, extensions and package dateReject: Unexpected container, content type, or missing provenance metadata
04SchemaPinned core and extension schemas; unknown fields retained and profiledReject: Required structure fails or a remote moving schema changes acceptance silently
05Release identityUnique (ocid, release.id) pairs plus canonical hashes across overlapsReject: Missing key or a different hash under an immutable release key is not quarantined
06Record reconciliationChanged OCIDs have a classified record lookup; sampled release IDs reconcileReject: Feed release disappears from the record view without a documented exception
07Merge provenanceCompiled and versioned values can be traced to source releasesReject: Derived current values cannot be explained from retained evidence
08Lifecycle grainNotice, process, lot, award and contract counts remain separateReject: UK6 is counted as signed, tags are collapsed, or tender is equated with bid-ready
09Recovery429/503 waits for Retry-After and repeats the identical requestReject: Watermark or cursor advances after a failed or unstored page
10Successful zeroHTTP, JSON container, frozen window, absent continuation and an explicit empty-envelope conformance warningReject: 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, optional stages, and limit=100 before 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.next is absent; do not depend on page length or a total count.
  • [ ] Honor Retry-After on 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.