API Reference

Every call the TendersHub API makes available. Every endpoint except /v1/match/vocab needs an X-API-Key header — get yours from the API Keys page of your developer dashboard after registering.

Download OpenAPI (3.1, JSON) for Postman, Insomnia or a client generator

/v1/match

Starter and above

Send a supplier profile, get ranked tender matches back with a fit score and reasons. The sector and region fields are required; a description of what the company does matters most (see Describing a company below). Optional sic_codes: the company's Companies House SIC codes. The score shows how well the tender fits the profile, from 0 to 1. It is for ranking, not a chance of winning: compare scores within one set of results rather than reading them as percentages. Optional min_score: only return tenders scoring at least this much (0 to 1). Leave it out to get the best matches whatever their score. Each tender appears once, even when it is published on more than one portal. The warnings list names anything about the request that weakens the matches (it still answers), and industry shows what we read from your SIC codes. Each match carries a master_id: pass it as tender_id to /v1/score or /v1/proposal to go deeper on that tender.

POST /v1/match
curl https://api.tendershub.co.uk/v1/match \
  -H "X-API-Key: th_..." \
  -H "Content-Type: application/json" \
  -d '{
    "sector": "Facilities / FM",
    "region": "Yorkshire",
    "description": "Commercial cleaning company, 40 staff, cleaning offices, schools and GP surgeries across West Yorkshire.",
    "sic_codes": ["81210"],
    "organisation_type": "small business",
    "contract_value_gbp": 120000,
    "annual_turnover": 1500000,
    "top_n": 5
  }'

# → 200 OK
{
  "matches": [
    {
      "master_id": "a1b2c3d4e5f6",
      "title": "Cleaning Services for Council Offices and Libraries",
      "buyer_name": "Leeds City Council",
      "notice_type": "Contract notice",
      "status": "open",
      "sector": "Facilities / FM",
      "cpv_code": "90910000",
      "regions": ["Yorkshire", "England", "UK-wide"],
      "value_min_gbp": 90000,
      "value_max_gbp": 150000,
      "value_text": "£90,000 - £150,000",
      "published_date": "2026-10-01",
      "closing_date": "2026-11-14",
      "url": "https://www.contractsfinder.service.gov.uk/notice/...",
      "score": 0.64,
      "reasons": ["Close match to what your company does", "Title mentions cleaning", "Sector match: Facilities / FM"]
    }
  ],
  "distinct_tender_cap_notice": null,
  "warnings": [],
  "industry": {
    "sic_codes": ["81210"],
    "sic_words": ["General cleaning of buildings"],
    "sectors": ["Facilities / FM"],
    "cpv_divisions": ["39", "55", "77", "90"],
    "guard_applied": true
  }
}

Describing a company

/v1/match and /v1/score rank tenders against the company you describe. Only two fields are required, but the matches are only as good as what you send: the description is read in full and counts for more than anything else.

FieldNeeded?What it does
sectorRequiredOne value from the sector list below.
regionRequiredWhere the company can deliver. One value from the region list below.
descriptionStrongly recommendedA sentence or two, in plain words, on what the company does and the work it bids for. This drives the ranking. A name alone, or a stock phrase like "a UK company seeking contracts", gives weak matches and a warning.
sic_codesRecommendedUp to 10 five-digit SIC 2007 codes, as Companies House lists them (e.g. ["62020"]). See below.
cpv_codesOptionalCPV codes for the work the company does (e.g. ["90910000"]). Tenders with matching codes rank higher.
contract_value_gbp, contract_value_min_gbp, contract_value_max_gbpOptionalThe size of contract the company goes for, in pounds.
annual_turnoverOptionalIn pounds. Contracts far too big for it rank lower.
organisation_typeOptionale.g. "small business", "charity". Tenders reserved for a kind of organisation you are not rank lower.

SIC codes

Send the company's SIC codes in sic_codes, or write them in the description as SIC 62020(both are read). They do two things. First, the words of each code (62020 is "Information technology consultancy activities") are added to what we match on, so a bare company name still says what it does. Second, tenders whose CPV codes are all for a different industry are left out: an IT consultancy is not sent road resurfacing or care-home places. A tender with no CPV code, or one for general business services, is never left out this way, and anything in your own description, sector or cpv_codes can only widen the industries we count you in. The industry object in the answer shows what we read. A code that is not on the SIC 2007 list gets a 422 naming it.

Worked example: a company you only know from Companies House
# What you have: a name and a SIC code
curl https://api.tendershub.co.uk/v1/match \
  -H "X-API-Key: th_..." \
  -H "Content-Type: application/json" \
  -d '{
    "sector": "IT / Digital",
    "region": "UK-wide",
    "description": "VALTECH LIMITED, a UK company seeking public sector contracts.",
    "sic_codes": ["62020"]
  }'

# → 200 OK: IT tenders, nothing whose CPV codes are for other trades, and a warning
{
  "matches": [ ... ],
  "warnings": ["description says little or nothing about what the organisation does, ..."],
  "industry": {
    "sic_codes": ["62020"],
    "sic_words": ["Information technology consultancy activities"],
    "sectors": ["IT / Digital"],
    "cpv_divisions": ["30", "32", "48", "64", "72"],
    "guard_applied": true
  }
}

# Better: add a sentence on what they actually do. The warning goes,
# and the matches follow their own words.
    "description": "Digital consultancy building e-commerce platforms and cloud hosting for retailers and public bodies.",
    "sic_codes": ["62020"]

Regions & sectors

The region and sector values are closed-vocabulary — send one of the exact values below (case-sensitive). Every endpoint rejects anything else with a 400, and a rejected call does not count towards your monthly quota.

Regions

UK-wideEnglandScotlandWalesNorthern IrelandLondonSouth EastSouth WestNorth WestNorth EastYorkshireEast MidlandsWest MidlandsEast of EnglandIrelandInternational

Sectors

Agriculture / FoodConstruction / WorksDefence / SecurityEducation / SkillsEnergy / EnvironmentFacilities / FMHealth / Social CareIT / DigitalProfessional ServicesSocial Services / CommunityTransport / LogisticsGeneral / Other

Limits & errors

Dev Starter and Dev Pro have three limits: calls per month, results per call, and distinct tenders per month. A tender counts once, the first time any endpoint or webhook shows it to your account. After that it is free to see again, in any month. Calls and distinct tenders both reset on the 1st of each month. Only successful calls count towards your monthly calls. Your developer dashboard shows how much of each you have used. Enterprise gives you the full tenders database and unlimited matching under a signed data licence, with no distinct-tender limit.

StatusWhen
401Missing, invalid or revoked X-API-Key.
403The endpoint is not on your plan (e.g. /v1/proposal on Starter), your plan is inactive, or every tender in the response would be new and your distinct-tender limit is used up.
400 / 422A parameter is missing or invalid, e.g. a region or sector not in the list above. The detail field says which.
404The tender_id does not exist or is no longer live.
429Monthly call limit reached. The message gives the reset date.

When some results are held back because of the distinct-tender limit, you still get a 200 with the tenders you can see, and distinct_tender_cap_notice says how many were held back.

We keep the details you send with each call - such as sector, region, description and amount - for 30 days, so we can look into a result with you if something doesn't look right and keep improving matches. After 30 days they are deleted. Your API key is never stored with them.

Webhooks

Pro and Enterprise only

Add a webhook on the Webhooks page of your developer dashboard with an https URL, an optional sector and region, and a match threshold. Once a day, from 08:00 UTC, we check the tenders added since its last delivery against it, then POST any whose match_pct(0 to 100, the number the threshold is compared with) is at or above the threshold, best first, up to 50 per delivery. Tenders outside the webhook's region are not sent. Each tender has the same fields as a /v1/matchresult. Tenders sent by webhook count towards your distinct-tender limit like any other response. Return any 2xx status to confirm delivery. If you don't, the same tenders are sent again the next day.

POST https://your-app.com/webhook
Content-Type: application/json
X-TendersHub-Signature: sha256=5d41402abc4b2a76b9719d911017c592...

{
  "event": "new_match",
  "webhook_id": 42,
  "sent_at": "2026-11-02T08:00:12+00:00",
  "tenders": [
    {
      "master_id": "a1b2c3d4e5f6",
      "title": "Cleaning Services for Council Offices and Libraries",
      "buyer_name": "Leeds City Council",
      "notice_type": "Tender",
      "status": "open",
      "sector": "Facilities / FM",
      "cpv_code": "90910000",
      "regions": ["Yorkshire", "England", "UK-wide"],
      "value_min_gbp": null,
      "value_max_gbp": 450000.0,
      "value_text": "£450,000",
      "published_date": "2026-11-01",
      "closing_date": "2026-12-04",
      "url": "https://www.contractsfinder.service.gov.uk/notice/...",
      "score": 0.72,
      "reasons": ["Sector match: Facilities / FM", "In your region (Yorkshire)"],
      "match_pct": 72.0
    }
  ]
}

Verifying the signature

Every delivery is signed with the webhook's signing secret, which you can see in your dashboard. X-TendersHub-Signature is sha256= followed by the hex HMAC-SHA256 of the raw request body. Compute it over the bytes exactly as received, before parsing the JSON, and compare in constant time.

Python
import hmac, hashlib

def is_valid(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, header or "")
Node.js
const crypto = require('crypto');

function isValid(rawBody, header, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret).update(rawBody).digest('hex');
  const a = Buffer.from(expected), b = Buffer.from(header || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}