Authentication, rate limits, and the ten routes an API key can call, with real request and response examples.
https://api.crierdata.com
This is the only environment — there is no separate sandbox or test host.
Every route below requires an Authorization header:
Authorization: Bearer <token>
The header accepts either of two credentials:
crd_live_, for a script or server integration. Getting one requires signing in first — see below. Keys do not currently expire.API keys can only be created from a signed-in session — never with another key.
POST /api-keys
Authorization: Bearer <session token>
Content-Type: application/json
{"name": "My integration"}
Response (201) — the only place the raw key value ever appears; it is not stored anywhere and cannot be retrieved again:
{
"id": "…",
"name": "My integration",
"key_prefix": "crd_live_a1b2c3d4",
"created_at": "…",
"last_used_at": null,
"revoked_at": null,
"key": "crd_live_<52 more characters>"
}
Key creation is available on paid plans only — Individual, Individual+, or Organisation. A free-plan account gets:
403 {"error": "the free plan has no API key capability"}
A key is only accepted on the ten routes documented below. Every other route refuses a key outright — 403, not 401: the credential is valid, but the route isn't open to keys.
403 {"error": "this endpoint is not available to API keys"}
| Status | Meaning | Real body |
|---|---|---|
401 |
No token, or the token doesn't resolve to a live credential (missing, expired, revoked, or the account is no longer active) | {"error": "no session token provided"} / {"error": "session is missing, expired, or revoked"} / {"error": "account is not active"} — an invalid key returns the same message as an invalid session |
403 |
The credential is valid, but this route, or this plan, refuses it | see "Key scope" above, and the per-route plan notes below |
Two independent layers.
| Kind | Free | Individual | Individual+ | Organisation |
|---|---|---|---|---|
search (POST /search submissions) |
25 | 1,000 | 1,000 | No cap |
browse (GET /properties/{uprn}, POST /map/pins) |
250 | 10,000 | 10,000 | No cap |
nl_search (POST /nl-search) |
10 | 200 | 200 | No cap |
Exports (POST /export) are available on paid plans. Per-plan limits are shown at checkout and in your dashboard.
A cap hit returns a structured 429, not just a message string:
429 {"error": "…", "kind": "search", "cap": 25, "plan": "free"}
An export whose row count exceeds your plan's per-file limit is rejected outright, never truncated, before a single row is fetched:
413 {"error": "export exceeds the per-file row limit for your plan -- narrow the filter", "kind": "export", "cap": <your plan's row limit>, "plan": "individual", "row_count": <rows your filter matched>}
A request that would exceed a cap is rejected before it does any work — it never costs you anything, whether that's a search, a property lookup, or a natural-language query.
Up to 4 requests may run concurrently across all accounts and keys. A request that takes longer than 30 seconds is stopped automatically:
504 {"error": "the request took too long and was stopped"}
In practice this almost never happens — even a completely unfiltered search across the whole dataset normally completes in well under a second.
POST /nl-search's own additional limitsBecause this route calls out to a language model to interpret your query:
400: "query must be non-empty text" / "query is N characters, over the 500-character limit".429, "rate limit exceeded: 20 calls in the last 60s (limit 20) -- try again shortly". This limit is shared across all callers and is a backstop, not a per-key allowance.There is no other rate limiting beyond the shared query-concurrency limit above.
| Capability | Free | Individual / Individual+ | Organisation |
|---|---|---|---|
POST /search |
Yes — must include a non-empty local_authority_derived filter (one authority at a time); locked fields (below) can't be used as a filter or sort field; results under 10 matching rows are suppressed (rows withheld, the total is still reported) |
Yes, unrestricted | Yes, unrestricted |
GET /properties/{uprn} |
Yes — identity and enrichment fields are masked (see below); the path takes an opaque token, not the real UPRN | Yes, real UPRN, unmasked | Yes, unmasked |
POST /map/pins |
No — 403 "individual property pins are not available on the free plan" |
Yes | Yes |
POST /nl-search |
No — 403 "natural-language search is not available on the free plan" |
Yes | Yes |
POST /export |
No — 403 "the free plan has no export capability" |
Yes — per-plan limits shown at checkout and in your dashboard | Yes, no cap |
GET /exports, GET /exports/{id}/download, DELETE /exports/{id} |
Yes (a free account can never have created one) | Yes | Yes |
GET /saved-searches, GET /organisations/saved-searches |
Yes — no plan restriction at all; a saved search is a name and a filters object, never query results | Yes | Yes |
POST /api-keys (creating a key) |
No — 403 "the free plan has no API key capability" |
Yes | Yes |
Free-plan responses carry real EPC attribute values — ratings, scores, floor area, construction age, tenure-as-declared, and the rest of the open EPC set — unchanged. Two categories of field are replaced instead:
Identity fields — address1, address2, address3, address, postcode, posttown, the raw local_authority field, postcode_area, certificate_number, lsoa_code, latitude, longitude.
Enrichment fields — every non-EPC layer: flood_zone and its scope fields, everything under constraints (conservation areas, listed buildings), everything under deprivation_and_tenure (deprivation indices, fuel poverty, census tenure), and land_registry (price_paid, last_sold_date).
Every one of those fields, on a free-plan response, becomes the fixed value {"locked": true} — never a string, 0, or null, so it can never be confused with a real value or a genuine null.
uprn is handled differently: a free-plan response never carries the real UPRN. It's replaced with a stable, opaque token — the same property always produces the same token, so repeated views of one property stay consistent. GET /properties/{uprn} on the free plan takes this token, not a real UPRN — send back exactly what /search returned in that row's uprn field.
The address object and the licensing notice are present on a free-plan property card too, with every address field individually locked. They are never omitted.
GET /properties/100010795308 — paid plan:
{
"uprn": 100010795308,
"address": {
"address1": "Flat 1",
"address2": "120 Coronation Street",
"address3": null,
"address": "Flat 1, 120 Coronation Street",
"postcode": "FY1 4QQ",
"posttown": "BLACKPOOL"
},
"restrictions_notice": "Ordnance Survey and Royal Mail Copyright and Database Right Notice\n\n…",
"identity": {
"local_authority_derived": "E06000009",
"postcode_area": "FY1",
"property_type": "Flat",
"built_form": "End-Terrace",
"construction_age_band": "1950-1966"
},
"headline_epc": {
"current_energy_rating": "E",
"current_energy_efficiency": 43,
"potential_energy_rating": "C",
"potential_energy_efficiency": 74,
"expires_within_12m": false
}
/* … 140 more fields, unmasked */
}
GET /properties/100010795308 — free plan, same real property
(request sends the token below, never the real UPRN):
{
"uprn": "b8864c2a6c89978b8005e9e590421a5f",
"address": {
"address1": {"locked": true}, "address2": {"locked": true},
"address3": {"locked": true}, "address": {"locked": true},
"postcode": {"locked": true}, "posttown": {"locked": true}
},
"restrictions_notice": "Ordnance Survey and Royal Mail Copyright and Database Right Notice\n\n…",
"identity": {
"local_authority_derived": "E06000009",
"postcode_area": {"locked": true},
"property_type": "Flat",
"built_form": "End-Terrace",
"construction_age_band": "1950-1966"
},
"headline_epc": {
"current_energy_rating": "E",
"current_energy_efficiency": 43,
"potential_energy_rating": "C",
"potential_energy_efficiency": 74,
"expires_within_12m": false
}
/* every constraints/deprivation_and_tenure/land_registry field is {"locked": true}; every other section is real */
}
POST /search, filtered to Blackpool, free plan (same row masked at
table grain — real local_authority_derived, property_type,
current_energy_rating; locked postcode_area; opaque uprn):
{
"rows": [
{
"uprn": "b8864c2a6c89978b8005e9e590421a5f",
"postcode_area": {"locked": true},
"local_authority_derived": "E06000009",
"property_type": "Flat",
"built_form": "End-Terrace",
"current_energy_rating": "E",
"potential_energy_rating": "C",
"expires_within_12m": false,
"gap_score": 2,
"recommendation_count": 5
}
],
"total_count": 8347,
"page": {"number": 1, "size": 2, "total_pages": 4174},
"suppressed": false
}
A free-plan query whose real total is under 10 returns no rows, with suppressed: true — the total is still the real number, so you can say "4 properties match — broaden your search," but no individual row is returned. Real example — Isles of Scilly, EPC band A:
{"rows": [], "total_count": 4, "page": {...}, "suppressed": true}
Every error response is {"error": "<message>"} at minimum. Three status codes carry structured extra fields you can key off without parsing the message text:
| Status | Extra fields | Fired by |
|---|---|---|
429 |
kind, cap, plan |
a monthly usage cap, or the NL-search rate limit |
413 |
kind, cap, plan, row_count |
an export whose row count exceeds the plan's per-file cap |
403 (key-scope only) |
— (message only) | a valid key against a route not on this document's list |
Status codes used across the ten routes in this document: 200, 201
(key creation only, not part of this scope), 204 (delete), 302
(export download redirect), 400 (malformed body, field, or filter),
401, 403, 404, 413, 429, 500, 503 (a required service
isn't available — not something you can fix from your side), 504
(request timeout).
POST /searchTakes a filter request and returns matching properties. Counts against the monthly search cap.
POST /search
Authorization: Bearer <token>
Content-Type: application/json
{
"filters": {
"local_authority_derived": ["E06000009"],
"current_energy_rating": ["E"]
},
"page": {"number": 1, "size": 25}
}
Real response (paid plan, truncated to 1 row for brevity; 8,347 real matches):
{
"rows": [
{"uprn": 100010795308, "postcode_area": "FY1", "local_authority_derived": "E06000009",
"property_type": "Flat", "built_form": "End-Terrace", "current_energy_rating": "E",
"potential_energy_rating": "C", "expires_within_12m": false, "gap_score": 2,
"recommendation_count": 5}
],
"total_count": 8347,
"page": {"number": 1, "size": 25, "total_pages": 334},
"suppressed": false
}
Errors: 400 (unknown field, invalid filter shape, or bad page/sort),
400 (free plan: missing scope — "the free plan requires a
non-empty 'local_authority_derived' filter -- area-level search has no
meaning with no area chosen"), 400 (free plan: locked field used as
a filter or sort field — "['flood_zone'] -- not available as a
filter on the free plan"), 429, 504, 500.
GET /properties/{uprn}Returns the full property record. {uprn} is a real UPRN for every paid plan, or the opaque token from a free-plan search response — see "The free-tier masking model" above. Counts against the monthly browse cap.
GET /properties/100010795308
Authorization: Bearer <token>
See the full real example under "The free-tier masking model" above.
Errors: 404 — {"error": "no property found for 100010795308"}
(echoes back whatever you sent — the real UPRN for a paid request, the
token for a free one; the real UPRN is never echoed back for a request
that sent a token). 429, 504, 500, 503.
POST /map/pinsReturns individual property rows with coordinates, inside a bounding box you supply. Capped at 2,000 rows per response — truncated: true reports honestly when the real match count is higher, rather than dropping rows silently. The bounding box can't exceed 0.5° on either axis — a larger request is refused with 400, not served truncated. Counts against the monthly browse cap. Not available on the free plan — a locked coordinate has no honest way to be plotted.
POST /map/pins
Authorization: Bearer <token>
Content-Type: application/json
{
"filters": {"local_authority_derived": ["E06000009"]},
"bbox": {"min_lat": 53.815, "max_lat": 53.822, "min_lon": -3.045, "max_lon": -3.03}
}
Real response (this exact bounding box around central Blackpool):
{
"rows": [
{"uprn": 10000107122, "postcode_area": "FY1", "local_authority_derived": "E06000009",
"property_type": "Flat", "built_form": "End-Terrace", "current_energy_rating": "C",
"potential_energy_rating": "C", "expires_within_12m": false, "gap_score": 0,
"recommendation_count": 2, "latitude": 53.8202218, "longitude": -3.044458}
],
"total_count": 2809,
"truncated": true
}
(2,000 of 2,809 real matches returned — truncated: true.)
The bounding box fields are min_lat/max_lat/min_lon/max_lon — not north/south/east/west.
Errors: 400 (missing, malformed, or oversized bounding box, or a
filter problem), 403 (free plan — "individual property pins are
not available on the free plan"), 429, 504, 500, 503.
POST /nl-searchTurns free text into a validated filters object, ready to send straight to POST /search. Every field and value it proposes is checked against the same rules /search itself enforces before anything is used — nothing reaches your data unvalidated. Not available on the free plan. Counts against the monthly nl_search cap.
POST /nl-search
Authorization: Bearer <token>
Content-Type: application/json
{"query": "EPC E, F or G in Blackpool"}
Response shape:
{
"query": "…",
"filters": { "…": "the same shape /search's \"filters\" accepts" },
"applied": [ "…human-readable strings describing what was understood…" ],
"not_applied": [
{"text": "…", "reason": "not_understood | unknown_field | invalid_shape | invalid_value | no_coverage", "detail": "…"}
]
}
A fragment that doesn't match any known field or place is folded into a text match against address and place fields, rather than being silently dropped — a query like "radon levels" still correctly returns real, honest zero matches rather than an error.
Errors: 400 (missing/non-string query, or an empty/over-length
query — see "Rate limits" above), 403 (free plan), 429 (rate
limit), 503, 500 (an unexpected error from the language model —
mapped to clean JSON rather than an unhandled crash).
POST /exportBuilds a CSV of every row matching filters (same shape as /search's own filters), and returns it in the response body. Not available on the free plan. Counts against the monthly export cap; the per-file row cap is checked first — an export that's too large is rejected before a single row is fetched.
POST /export
Authorization: Bearer <token>
Content-Type: application/json
{"filters": {"local_authority_derived": ["E06000053"], "current_energy_rating": ["B"]}}
Response: 200, Content-Type: text/csv,
Content-Disposition: attachment; filename="crierdata_export_<ref>_<date>.csv".
See "The export watermark" below for what the file actually contains.
Errors: 403 (free plan), 413 (row cap — structured, see "Rate
limits"), 429 (monthly export cap — structured), 400 (filter
validation), 504, 500, 503.
GET /exportsYour export history (or, on an organisation plan, every export logged under the organisation). No plan restriction. Supports offset/limit pagination — this history is never trimmed.
GET /exports?limit=20&offset=0
Authorization: Bearer <token>
{
"exports": [
{
"id": "…", "requested_at": "…", "row_count": 32,
"status": "available", "expires_at": "…",
"filters_summary": "local_authority_derived: E06000053 · current_energy_rating: B",
"scheduled_query_id": null
}
],
"total_count": 1
}
status is one of available, unavailable (something went wrong
after the export was already earned — you still received the CSV, but
it can't be re-downloaded), expired (past the 90-day retention
window), deleted.
Errors: 400 (non-integer limit/offset), 503. limit is
silently clamped, not rejected, if out of range.
GET /exports/{id}/downloadChecks you own the export and that it's currently available, then returns a link to download it. Default: 302 redirect to a short-lived link. ?json=1: the same link returned as {"url": "..."} instead, for a caller that wants the URL itself rather than following a redirect.
GET /exports/{id}/download
Authorization: Bearer <token>
or
GET /exports/{id}/download?json=1
Authorization: Bearer <token>
{"url": "https://…?X-Amz-Signature=…"}
Errors: 400 (id isn't a valid identifier), 404 (not found, not
yours, not currently available — including the case where the file is
no longer actually there even though its record says otherwise),
503 (the file's status couldn't be verified).
DELETE /exports/{id}Deletes the file (deleting an already-deleted one still succeeds) and marks it deleted in your history. You can delete your own exports; on an organisation plan, an admin can also delete a colleague's.
DELETE /exports/{id}
Authorization: Bearer <token>
204, no body, on success.
Errors: 400 (id isn't a valid identifier), 404 (not found or not
yours), 503 (safe to retry either way).
GET /saved-searchesEvery saved search on your account, newest first. No plan restriction — a saved search is a name plus a filters object, never query results.
GET /saved-searches
Authorization: Bearer <token>
{
"saved_searches": [
{
"id": "…", "name": "…",
"filters": {"local_authority_derived": ["E06000053"], "current_energy_rating": ["B"]},
"created_at": "…"
}
]
}
The filters aren't checked when you save them — only when you actually
run the search. A saved search built around a field that's since
changed will only fail the normal /search way, the next time you run
it.
Errors: 503.
GET /organisations/saved-searchesEvery search shared with your organisation, by any member — including your own shares. Requires you to actually be a member of an organisation.
GET /organisations/saved-searches
Authorization: Bearer <token>
{
"shared_searches": [
{
"id": "…", "name": "…", "filters": {"…": "…"}, "shared_at": "…",
"owner_name": "…", "owner_email": "…", "own": false
}
]
}
A share whose owner has since left the organisation disappears from this listing immediately.
Errors: 404 ({"error": "not a member of an organisation"} — this
covers every reason you might not see a listing here, so it doesn't
reveal which one applies), 503.
Every CSV POST /export produces (and everything downloaded afterward
via GET /exports/{id}/download, since it's the same file) carries
two things designed to trace a specific export if a copy of it ever
surfaces somewhere it shouldn't:
A leading stamp line. The very first line of the file is a
#-prefixed comment row naming the export's own reference code,
the account email it was issued to, and the date — before the real
CSV header even appears:
# CrierData export -- reference 71c13378 -- account [email protected] -- generated 2026-08-26
Fake rows woven into the real data. For any export of 20 rows
or more, extra rows are inserted roughly every 200–500 real rows
(the exact spacing varies per export, so it's not predictable in
advance). Each fake row carries a real-looking shape — same
columns, same rough value ranges — but a UPRN drawn from a block
(9,000,000,000,000–9,999,999,999,999) that can never collide
with a real UPRN (a real UPRN is at most 12 digits). An export
under 20 rows gets no fake rows at all.
Real, unedited example (32 genuine rows, one fake row inserted at position 12):
# CrierData export -- reference 71c13378 -- account [email protected] -- generated 2026-08-26
uprn,postcode_area,local_authority_derived,property_type,built_form,current_energy_rating,potential_energy_rating,expires_within_12m,gap_score,recommendation_count
192000670,TR21,E06000053,Bungalow,Mid-Terrace,B,A,False,1,5
192000816,TR21,E06000053,Flat,Semi-Detached,B,B,False,0,1
…
9704218232264,TR21,E06000053,Flat,End-Terrace,B,B,False,0,3 ← the inserted row
…
What this means for an integration: don't assume every row in an export file is a real property. If you need to distinguish a real row from a watermark row — you shouldn't normally need to, this exists purely to trace leaks, not as a data-quality signal — a UPRN outside the real ~12-digit range is not a data error.