CrierData API Reference

Authentication, rate limits, and the ten routes an API key can call, with real request and response examples.

Base URL

https://api.crierdata.com

This is the only environment — there is no separate sandbox or test host.


Authentication

Every route below requires an Authorization header:

Authorization: Bearer <token>

The header accepts either of two credentials:

Getting an API key

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"}

Key scope

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"}

401 vs 403

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

Rate limits and usage caps

Two independent layers.

Monthly usage caps, per account, per calendar month

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.

Query concurrency and timeout

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 limits

Because this route calls out to a language model to interpret your query:

There is no other rate limiting beyond the shared query-concurrency limit above.


Plan gating — what each plan can reach

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 No403 "individual property pins are not available on the free plan" Yes Yes
POST /nl-search No403 "natural-language search is not available on the free plan" Yes Yes
POST /export No403 "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) No403 "the free plan has no API key capability" Yes Yes

The free-tier masking model

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 fieldsaddress1, 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.

Real example — the same property, three ways

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
}

Suppression

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}

Error shapes

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).


Endpoints

POST /search

Takes 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/pins

Returns 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-search

Turns 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 /export

Builds 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 /exports

Your 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}/download

Checks 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-searches

Every 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-searches

Every 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.


The export watermark

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:

  1. 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

  2. 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,0009,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.