Error handling
Every error arrives under an error key. Most are an object with a stable code, a message and a status, and most of those also say what to try in a hint and link to their own entry on this page in docs. A few responses still use an older shape, or send error as a plain message string. Branch on the HTTP status first, switch on error.code case-insensitively, and check whether error is a string before you read error.code. message and hint are written for people and may be reworded, so never match on them.
Error format
Errors come in three shapes. Since 21 October 2026 almost all are the first; a client still has to survive the other two. The codes differ in case: the platform's are UPPER_SNAKE_CASE (INVALID_API_KEY, RESOURCE_NOT_FOUND) and the endpoints' are lowercase (uprn_not_found), so a client comparing err.code == "BAD_REQUEST" silently misses invalid_parameter. Normalise the case before you compare.
| Shape | Looks like | Where it comes from |
|---|---|---|
| Standard object | {"error": {"code": "uprn_not_found", "message": "...", "hint": "...", "docs": "...", "status": 404}} | Almost every error. Always code, message and status. Every 404, and the address, parameter and access codes below, add hint and docs; key and token-balance refusals do not |
| Older coded object | {"error": {"code": "invalid_uprn", "message": "..."}} | Lowercase code with no status, from some endpoints rejecting your parameters |
| Plain string | {"error": "Invalid UPRN or address token."} | No code at all. A few 400s still answer this way; a 404 no longer does. Read it as the message |
{
"error": {
"code": "uprn_not_found",
"message": "We don't hold a property with UPRN 10091234567. Homedata covers residential and commercial addresses in the UK; land parcels and other non-postal UPRNs aren't covered. You weren't charged for this call.",
"hint": "For one home, use /address/match/?address=<address>&postcode=<postcode>. For every address in a postcode, use /address/postcode/<postcode>/. Use the UPRN returned for the property.",
"docs": "https://homedata.co.uk/docs/errors#uprn_not_found",
"status": 404
}
}
| Field | Type | Description |
|---|---|---|
| error | object | string | Present on every non-2xx response, but not always an object: a few 400s put a plain message string here. Check the type before reading code. |
| error.code | string | Machine-readable and stable: switch on this. Platform codes are UPPER_SNAKE_CASE, endpoint codes lowercase, so compare case-insensitively. Each one has an entry under Error codes. |
| error.message | string | What went wrong, written for people. A free error's message ends "You weren't charged for this call." May be reworded, so don't match on it. |
| error.hint | string | What to try next, such as the route to use instead. Safe to show to a person; may be reworded. Not on every error. |
| error.docs | string | A link to the code's entry on this page, https://homedata.co.uk/docs/errors#<code>. Not on every error. |
| error.status | integer | Mirrors the HTTP status code, so the body is self-describing if you log it on its own. The older coded object and the plain string lack it, so read the HTTP status itself. |
An empty answer is not an error
When a call works but finds nothing, it answers 200 with its usual fields, empty, plus a reason that says why and links to the same entries as an error. An empty 200 is charged at the endpoint's normal rate, so check your input before calling again. Errors are free: see HTTP status codes.
{
"count": 0,
"results": [],
"reason": {
"code": "no_match",
"message": "Nothing matched these filters.",
"hint": "Check the filter values, or widen them.",
"docs": "https://homedata.co.uk/docs/errors#no_match"
}
}
HTTP status codes
Only a served response is charged. Any 4xx other than a 402, and any 5xx, is refunded automatically however your key is billed: a charge taken before the failure comes back. On a wallet key X-Tokens-Charged reads 0 on that response; on a quota key the call is returned to your allowance and no token header is sent.
OK
Request succeeded. Response body contains the requested data. If it found nothing, it says why in a reason, and it is still charged.
Bad request
A parameter is missing or in the wrong format. Where it can, the error names it in error.parameter and the message gives the format we expect. See Bad requests for each code.
Unauthorized
Do not branch on 401 to detect a missing key: a request with no key returns 403, not 401. 401 is rare and means something else: the key was recognised but its server-side configuration is missing. Only the Explore and listing-address endpoints return it, and the code is the lowercase auth_error, not an upper-case constant.
Insufficient tokens
The request costs more tokens than your balance holds. Both response shapes use insufficient_tokens and say what the request needs (error.required) and what you have (error.available), in tokens. Most endpoints also send error.status and error.topup_url, a link that opens a one-off top-up:
Listings, reveals and listing-address return the smaller shape, with no status or topup_url field:
Top up and retry. Nothing else about the request needs changing, and retrying without topping up will not clear it. Where the response carries error.topup_url, send people there rather than to a link you have saved.
Forbidden
Every key problem lands here, including sending no key at all, but not every 403 is a key problem. Missing, wrong, revoked or unknown keys all arrive as 403 carrying INVALID_API_KEY. "Authentication credentials were not provided." means no key reached us, so check you are sending the Authorization: Api-Key header before you suspect the key itself. Running out of tokens is not a 403 at all: that is 402 insufficient_tokens.
A widget embed token (it starts wt_) sent in place of an API key gets its own code, widget_token_not_api_key. A widget token only works inside the embed snippet; the response links to the embed docs and to your API keys page, where you get a key that calls the API. Nothing is charged.
Data limited to enterprise accounts answers enterprise_only: property listings for accounts opened after 16 September 2026, and listing filters other than uprn for any account that is not enterprise. The key and the request are fine, nothing is charged, and topping up, retrying or rotating the key will not clear it. The body carries a contact object saying who to talk to about access, an enquire_url, and alternatives: any paid route you can call instead. Show those to the caller rather than retrying.
Not found
We hold no record of what you asked for, or the route does not exist. Every 404 is a coded object with a hint and a docs link. For a property it is usually uprn_not_found: the UPRN is a land parcel or another one that never gets post. An incomplete route, such as /address?q=… or /property/ with no UPRN, gets RESOURCE_NOT_FOUND with a hint naming the route to use. See Not found for each code.
A UPRN we hold no record of returns 404, and an empty or malformed one returns 400. Both are refunded in full (on a wallet key X-Tokens-Charged reads 0), and repeating the call gives the same answer, so treat it as final rather than retrying.
Internal server error
A 500 can mean a temporary outage or an internal error. Check the JSON error.code: service_unavailable means retry with backoff, respecting Retry-After. Endpoint-specific temporary errors include rmas_unavailable and rmas_index_not_ready. The retry sample also handles 503 responses from API gateway routes. Other 500 errors follow the normal error path; contact us if they persist.
Gateway timeout
Request took too long to process. Retry with exponential backoff. If frequent, the endpoint may be under heavy load.
Error codes
Each code below is the one in error.code (or reason.code on an empty answer), and each has its own link: an error's docs field opens it here. Codes are a contract; the wording of message and hint may change. A code is not tied to one status everywhere, so branch on the HTTP status first, then switch on error.code case-insensitively.
Keys and access
A 403 is not always a key problem. Only INVALID_API_KEY means the key itself is wrong.
The key is missing, wrong, revoked or unknown, or not allowed on this endpoint. A request with no key at all lands here too, not on 401, with the message "Authentication credentials were not provided."
What to try: Send the key as Authorization: Api-Key <your key>, not Bearer, then check it on your API keys page.
You sent a widget embed token (it starts wt_) where an API key goes. A widget token only works inside the embed snippet on your allowed domain, and it has no secret half, so it can never authenticate an API call.
What to try: To call the API directly, use an API key: error.api_keys_url links to your API keys page. To show data on your site, keep the token in the embed snippet and follow error.embed_docs_url, the embed docs.
The data is limited to enterprise accounts, so topping up will not unlock it. Property listings (/property_listings/ and /property_listings/<id>/) answer this way for accounts opened after the switch to tokens on 16 September 2026, for whom listings are arranged per customer, and listing filters other than uprn answer this way for any account that is not enterprise. The key and the request are fine.
What to try: Read error.alternatives: it is always present, lists any paid route you can call instead (for example a home's listing history from /property_sale_events/?uprn=<UPRN>), and is empty when there is none. error.contact says who to talk to about enterprise access and error.enquire_url links to the enquiry form, so show those to the caller rather than retrying. Before 22 October 2026 listings refused with premium_endpoint and other filters with a plain string; switch on enterprise_only instead.
The key was recognised but its server-side configuration is missing. Only the Explore and listing-address endpoints send it. It is not what a missing key gets; that is 403.
What to try: Contact us: nothing on your side will clear it.
Token balance
The call costs more tokens than your balance holds. error.required and error.available say how many. Most endpoints also send error.status and error.topup_url, a link to a one-off top-up; listings, reveals and listing-address omit both.
What to try: Top up, then send the same request again. Where the response has error.topup_url, send people to that link rather than one you have saved, as it can change. Retrying without topping up will not clear it.
Not found
Every 404 is free and final: the same call gives the same answer.
We hold no property with that UPRN. It is usually a land parcel or another UPRN that never gets post, which we do not cover (see coverage). Also sent by /property_lr_titles/?property=<UPRN>, /property_lr_titles/<UPRN>/ and /address/retrieve/<UPRN>/. A list filtered by such a UPRN answers 200 with this as its reason, and that call is charged.
What to try: The API's hint: "For one home, use /address/match/?address=<address>&postcode=<postcode>. For every address in a postcode, use /address/postcode/<postcode>/. Use the UPRN returned for the property."
The same as uprn_not_found, from older property endpoints.
What to try: As for uprn_not_found.
We hold the property, but no Land Registry title is linked to it (/property_lr_titles/<UPRN>/). /property_lr_titles/?property=<UPRN> for a home with no title at all answers 200 with this as its reason, and is charged.
What to try: Titles cover England and Wales. A flat or a new build may sit under a parent title: try ?title_no=.
We hold no data for that postcode.
What to try: Check that the postcode is complete and in Great Britain, for example SW1A 2AA.
No listing has that id.
What to try: Check the id. Listings are found by UPRN with /property_listings/?uprn=; for accounts opened after 16 September 2026, listings are arranged per customer.
The URL, or the id in it, does not exist. Since 29 October 2026 an incomplete address or property route gets this with a hint naming the right one: /address or /property with nothing after it, with or without the trailing slash or the /api/ prefix, and whatever query you add, such as /address?q=… or /address/?postcode=….
What to try: For one home, use /address/match/?address=<address>&postcode=<postcode>. For every address in a postcode, use /address/postcode/<postcode>/. For a property by UPRN, use /property/<uprn>/. Otherwise check the URL and the id.
An older lowercase code a few endpoints still send for an absent record.
What to try: Treat any 404 as an absent record rather than matching on one code.
Address lookup found no single address
/address/match/ sends these as a free 422, and still returns its original reason string (no_match or multiple_matches) beside the error object. The older address search, /address/find/, still served to keys created before 8 October 2026, sends the first two as a 200 reason instead, and that call is charged.
No address matched what you sent.
What to try: Send a house number or name, street and postcode.
What you sent looks like an OS grid reference, such as TL4256 5508. Address lookup matches postal addresses only.
What to try: Send the postal address instead. Land with no postal address is not covered.
More than one address matched.
What to try: Add the flat or unit, and the house number or name.
Bad requests
Every 400 is free. Fix the request, then send it again.
A list endpoint got a filter in the wrong format, or none of its required filters: /property_lr_titles/, /property_sales/, /property_sale_events/, /property_listings/ (for accounts opened after 16 September 2026, listings are arranged per customer) and /agent_stats/. error.parameter names the filter and the message gives the format we expect. A custom property call (/property/<uprn>/?with=) where none of the add-ons is one we know also gets it, with error.valid_slugs listing the names you can use. The older address search sends it as a 422.
What to try: Fix the value the message names. The API reference lists every parameter and its format.
The older address search, /address/find/, got q shorter than 2 characters.
What to try: Send at least 2 characters, or use /address/match/.
/address/postcode/<postcode>/ got something that is not a whole postcode.
What to try: Send the full postcode, for example SW1A 2AA.
The UPRN in the URL is not digits or a uprn_token.
What to try: Send the UPRN as digits only, as address lookup returns it.
/address/match/ needs both address and postcode.
What to try: Send both.
/live-listings/search/ was asked for a page past the 10,000th result (page times page_size over 10,000, for example page 51 at a page_size of 200). Searches that include withdrawals, and back_on_market searches, are not limited.
What to try: Narrow the location or the filters. Live listings search is arranged per customer.
The request could not be processed as sent, for example a body that is too large.
What to try: Check the request against the API reference.
Empty answers
These are not errors. The call worked and found nothing, so it answers 200 with the usual fields (an empty results, suggestions or addresses) plus a reason object, and it is charged at the endpoint's normal rate. uprn_not_found, title_not_found, address_no_match and looks_like_grid_ref can also arrive as a reason.code, as described above. Nearby searches (schools, healthcare, crime, risks and similar) carry no reason: inside coverage, none within the radius is the answer itself.
A list endpoint's filters matched nothing; a postcode has no addresses; /lr-sales/ found no confirmed sales; agent market share found no listings in the outward postcodes; or a home's titles were all excluded by title_no or ownership.
What to try: Check the filter values, or widen them.
Our side
Every 5xx is free. A 500 is either a temporary outage or an internal error, so read error.code before deciding to retry: the retry sample retries only the temporary codes below.
Something failed on our side. It is logged automatically.
What to try: Handle it as a normal error rather than retrying in a loop. If it persists, contact us.
The rental appraisal's search index is still being built.
What to try: Retry with backoff; it clears once the index is ready.
The request took too long upstream.
What to try: Retry with backoff.
Handling errors in code
# Python: branch on the status, then the code def raise_for_homedata_error(response): """Raise for a failed response. Returns None when the record is absent.""" err = response.json().get("error", {}) # Shape 3: a plain string. Check before you call .get() if isinstance(err, str): message, code = err, "" else: message = err.get("message", "") # Codes are UPPER_SNAKE_CASE from the platform, lowercase from # endpoints, so always normalise before comparing code = (err.get("code") or "").upper() # Status first: it is always there, error.status is not if response.status_code == 403: # Not every 403 is a key problem. if code == "INVALID_API_KEY": # A missing key arrives here too, not on 401 raise AuthError(message) if code == "WIDGET_TOKEN_NOT_API_KEY": # A widget embed token (wt_...) was sent as the API key. It only # works in the embed; error.api_keys_url is where to get a key. raise AuthError(message) # ENTERPRISE_ONLY and the rest: the key is fine, so do not ask for # a new one. error.alternatives lists any route you can call instead. raise ApiError(f"403: {message}") elif response.status_code == 402: raise AllowanceError(message) # retrying will not help elif response.status_code == 404: return None elif response.status_code == 400: # code may be "" on the plain-string shape raise ValidationError(code or "bad_request", message) else: raise ApiError(f"{response.status_code}: {message}") response = requests.get(url, headers=headers) # Raises, or gives back None when the record simply is not there data = response.json() if response.ok else raise_for_homedata_error(response)
Rate limiting
An authenticated response reports your usage in headers. Wallet keys get the token headers below and no rate-limit headers at all; quota keys get the X-RateLimit-* family instead.
| Header | Description |
|---|---|
| X-Tokens-Charged | Tokens this request cost (0 when it was refunded) |
| X-Tokens-Balance | Tokens left on your balance after this request |
| X-Call-Weight | Same number as X-Tokens-Charged, kept for older integrations |
| Retry-After | On a temporary-unavailability 500: seconds to wait before retrying |
Best practice: back off on transient errors
# Python: retry with backoff import time import requests def api_call(url, headers, max_retries=3): for attempt in range(max_retries): response = requests.get(url, headers=headers) code = "" if response.status_code == 500: try: payload = response.json() except ValueError: payload = None error = payload.get("error") if isinstance(payload, dict) else None if isinstance(error, dict): code = str(error.get("code", "")).lower() temporary = code in ( "service_unavailable", "rmas_unavailable", "rmas_index_not_ready" ) if response.status_code in (503, 504) or (response.status_code == 500 and temporary): wait = 2 ** attempt # 1s, 2s, 4s retry_after = response.headers.get("Retry-After", "") if retry_after.isdigit(): wait = max(wait, int(retry_after)) time.sleep(wait) continue # Anything else that failed goes through raise_for_homedata_error above, # so an error body is never returned as if it were data if not response.ok: # return, so a 404's None is not thrown away return raise_for_homedata_error(response) return response.json() raise Exception("Still failing after retries")
Integrate into your own product
Pay as you goHomedata API errors always arrive under an error key. Almost all are an object with a stable code, a message and a status, and most add a hint saying what to try and a docs link to that code on this page. A few still use an older lowercase object with no status, or a plain message string. Branch on the HTTP status, compare codes case-insensitively, and handle all three shapes.
Structured as JSON · queryable by UPRN or postcode · ready to embed in any application
Exact measurements
Real values (distances, concentrations, counts) not rounded ratings
29 million UK properties covered
Every address queryable by UPRN or postcode
REST API
JSON responses and OpenAPI docs: first call in under 5 minutes
Pay as you go: 100 tokens = £1 across every endpoint, no subscription needed. Bonus tokens with an optional monthly subscription, and your first top-up matched 100%. See pricing →
Sources
Further reading