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
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.
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
}
}/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.
| Field | Needed? | What it does |
|---|---|---|
| sector | Required | One value from the sector list below. |
| region | Required | Where the company can deliver. One value from the region list below. |
| description | Strongly recommended | A 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_codes | Recommended | Up to 10 five-digit SIC 2007 codes, as Companies House lists them (e.g. ["62020"]). See below. |
| cpv_codes | Optional | CPV 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_gbp | Optional | The size of contract the company goes for, in pounds. |
| annual_turnover | Optional | In pounds. Contracts far too big for it rank lower. |
| organisation_type | Optional | e.g. "small business", "charity". Tenders reserved for a kind of organisation you are not rank lower. |
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.
# 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"]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.
UK-wideEnglandScotlandWalesNorthern IrelandLondonSouth EastSouth WestNorth WestNorth EastYorkshireEast MidlandsWest MidlandsEast of EnglandIrelandInternationalAgriculture / FoodConstruction / WorksDefence / SecurityEducation / SkillsEnergy / EnvironmentFacilities / FMHealth / Social CareIT / DigitalProfessional ServicesSocial Services / CommunityTransport / LogisticsGeneral / OtherDev 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.
| Status | When |
|---|---|
| 401 | Missing, invalid or revoked X-API-Key. |
| 403 | The 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 / 422 | A parameter is missing or invalid, e.g. a region or sector not in the list above. The detail field says which. |
| 404 | The tender_id does not exist or is no longer live. |
| 429 | Monthly 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.
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.
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
}
]
}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.
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 "")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);
}