Skip to main content

The model in one line

Vepler search is a two-step funnel. You type-ahead for free, and you pay only when a user picks a result and you fetch its full record.

Discovery is free. Retrieval is charged once per address.

Call /v1/search/suggest as freely as your rate limits allow — autocomplete on every keystroke costs nothing. The charge lands only on /v1/search/retrieve, and the same address in the same billing period is charged once, no matter how many times you fetch it.
Everything else in this guide is detail on those two calls: the levers that make results sharper, the autocomplete patterns that make the box feel instant, and the handful of habits that keep your invoice tracking real usage rather than request volume.
New to the billing side? This guide covers the search flow end-to-end. For the why behind charge-once and how it compares to session-token pricing, read Address Billing & Sessions.

Quick start

Two calls: surface suggestions as the user types, then retrieve the one they choose.
import { SDK } from '@vepler/sdk';

const vepler = new SDK({ apiKey: process.env.VEPLER_API_KEY });

// 1. Free — surface candidate addresses as the user types.
const suggest = await vepler.search.searchSuggest({ q: '10 Downing' });

// The user picks an address row; its `uprn` is the key you carry into retrieval.
const chosen = suggest.data.find((r) => r.type === 'address' && r.uprn);
if (!chosen?.uprn) return;

// 2. Charged (once per UPRN, per period) — fetch the full record on selection.
const record = await vepler.search.searchRetrieve({
  uprn: chosen.uprn,
  searchId: suggest.searchId ?? undefined, // links the selection to the search
});

console.log(record.formatted, record.coordinates);
All requests authenticate with the x-api-key header and run server-side — your API key must never reach the browser. Put the search endpoints behind your own backend and proxy the results to your UI.

Step 1 — Suggest (free discovery)

GET /v1/search/suggest?q={query}
Fuzzy autocomplete across UK addresses and locations. It detects what the user is typing (postcode, address, place name) and returns ranked suggestions. Suggest is never charged. Address suggestions come back as a discovery projection — a label, the uprn, and the matched substrings for highlighting. They deliberately carry no coordinates, postcode, or classification; those arrive in the retrieve step. This is what lets discovery stay free.

Parameters

q
string
required
The search query. Anything from a partial postcode to a full address line.
source
string
Restrict the sources searched: address (individual properties) or location (postcodes, towns, places). Omit to search both. Pair with source=address for the most predictable proximity behaviour.
classification
string
default:"residential"
residential, commercial, or all. Filters address results by property class.
granularity
string
default:"address"
address (one row per property) or street (one row per named street / USRN — useful for early keystrokes and street-level pickers).
addressable_only
boolean
default:"false"
When true, excludes historical and superseded addresses — only live, addressable records.
lat
number
Latitude (WGS84, −90 to 90) of a centre point to make results proximity-aware. Must be paired with lng.
lng
number
Longitude (WGS84, −180 to 180). Must be paired with lat.
radius_meters
integer
default:"10000"
Radius around the centre point (1–200,000 m). Required when geo_mode=restrict.
geo_mode
string
default:"bias"
How the centre point is applied — bias (soft: near matches rank higher, distant matches still returned) or restrict (hard: only address results within radius_meters are returned). See Proximity.
limit
integer
default:"10"
Number of results, 1–100.
offset
integer
default:"0"
Pagination offset (max 10,000).
search_id
string (uuid)
A candidate-set token from a previous suggest response. Pass it back on subsequent keystrokes to narrow within the same match set without a new data fetch. See Candidate sets.

Response

The response is a standard list envelope. Address rows are the slim discovery projection:
{
  "object": "list",
  "url": "/v1/search/suggest",
  "data": [
    {
      "id": "loc-sw1a1aa",
      "title": "SW1A 1AA",
      "description": "Westminster, London",
      "type": "postcode",
      "source": "location",
      "relevanceScore": 0.95
    },
    {
      "id": "addr-100023336956",
      "title": "10 Downing Street, London, SW1A 2AA",
      "type": "address",
      "source": "address",
      "relevanceScore": 0.92,
      "uprn": "100023336956",
      "main_text": "10 Downing Street, London, SW1A 2AA",
      "matched_substrings": [{ "offset": 0, "length": 2 }]
    }
  ],
  "has_more": true,
  "total_count": 15,
  "search_id": null,
  "set_complete": false,
  "metadata": { "processingTime": "45ms" }
}
data
array
The suggestions. type is address, postcode, location, etc.; address rows carry uprn.
search_id
string | null
Set once the query narrows to a small exact match set. Carry it into the next keystroke and into retrieve. null when no candidate set has formed yet.
set_complete
boolean
true when data already holds the complete match set — you can filter further keystrokes locally instead of calling the API again.
has_more
boolean
Whether more results exist beyond this page (limit/offset).
Through the SDK, the same fields are camelCased — searchId, setComplete, hasMore, totalCount, mainText, matchedSubstrings.

Step 2 — Retrieve (charged selection)

GET /v1/search/retrieve?uprn={uprn}&search_id={search_id}
The user picked a suggestion — now fetch its full record. This is the chargeable step: address lines, postcode, coordinates, and classification, keyed on the uprn from the chosen suggestion.

Parameters

uprn
string
required
The uprn of the chosen suggestion. Its full record is returned and charged.
search_id
string (uuid)
The search_id from the suggest response this selection came from. Supplying it completes that search session and records the retrieval against it. Omit it (or pass an expired / unknown one) and retrieve still works as a direct resolve, recorded as its own transaction.

Response

{
  "object": "address",
  "uprn": "100023336956",
  "line_1": "10 Downing Street",
  "line_2": "",
  "line_3": "",
  "post_town": "London",
  "county": "Greater London",
  "postcode": "SW1A 2AA",
  "formatted": "10 Downing Street, London, SW1A 2AA",
  "coordinates": { "lat": 51.5034, "lng": -0.1276 },
  "classification_code": "RD",
  "search_id": "3f8a2c1e-9b7d-4e6f-8a2c-1e9b7d4e6f8a"
}
StatusMeaning
200Full record returned (and charged, unless already claimed this period).
400Invalid uprn or search_id.
404UPRN not found — nothing is ledgered or charged.

Getting autocomplete right

A search box that fires a raw request on every keystroke feels laggy and burns your rate limit. Four patterns make it feel instant. None of them are about cost — discovery is free — they are about latency and cleanliness.
1

Debounce keystrokes (~300ms)

Wait for a short pause in typing before firing. One request per settled query instead of one per character.
2

Set a minimum query length (3 chars)

Below 2–3 characters, matches are too broad to be useful. Show a “keep typing…” hint instead of querying. Fewer noisy round-trips, sharper first results.
3

Cancel superseded requests

Attach an AbortController to each request and abort the previous one when a new keystroke fires. This prevents a slow early response from clobbering a fast later one (out-of-order results).
4

Reuse the search_id

Once a response returns a search_id, pass it back on the next keystroke. The server narrows within the cached candidate set instead of re-running the whole search — faster, and it keeps the session linked for clean billing attribution.
// A minimal, correct autocomplete controller.
const MIN_QUERY_LENGTH = 3;
let inFlight: AbortController | null = null;
let searchId: string | undefined;

async function onQueryChange(q: string): Promise<void> {
  q = q.trim();
  if (q.length < MIN_QUERY_LENGTH) return; // keep typing…

  inFlight?.abort();                 // supersede the previous request
  const controller = new AbortController();
  inFlight = controller;

  const res = await vepler.search.searchSuggest(
    { q, searchId },                 // reuse the candidate set
    { signal: controller.signal },   // supersede via AbortController
  );
  if (controller.signal.aborted) return;

  searchId = res.searchId ?? undefined;
  render(res.data);
}
Debounce ~300ms, minimum 3 characters, and abort-on-supersede is the combination we use in our own products. It turns a chatty box into one clean request per intent.

Flexibility — the tuning levers

Proximity: bias vs restrict

Pass the map centre (or the user’s location) as lat/lng to make results local. Two modes:

geo_mode=bias

Soft preference (default). Nearby matches rank higher, but strong textual matches from further away still appear. Best for a general search box where the user might mean somewhere else.

geo_mode=restrict

Hard cutoff. Only address results within radius_meters of the point are returned. Best for “search within this area” experiences. Requires radius_meters.
// "Addresses within 2km of the map centre, closest first."
const local = await vepler.search.searchSuggest({
  q: 'high street',
  source: 'address',
  lat: 51.5034,
  lng: -0.1276,
  geoMode: 'restrict',
  radiusMeters: 2000,
});

Source and classification

  • source=address for property-level typeahead; source=location for postcode / town pickers. Omit to blend both.
  • classification=residential (default) / commercial / all to scope address results to the property class your product cares about.

Granularity: address vs street

granularity=street collapses results to one row per named street (USRN). It shines on early keystrokes and in “pick a street” flows, where a list of every flat on the road is noise.

Candidate sets: narrow without re-fetching

When a query resolves to a small exact match set, the response carries a search_id and, often, set_complete: true. That is your signal to stop calling the API:
1

First real query returns a candidate set

The response includes search_id. If set_complete is true, data is the entire match set.
2

Further keystrokes narrow locally

When set_complete is true, filter the rows you already hold client-side. No new request, no latency, nothing to meter.
3

Reuse search_id when you do call again

If you need the server (e.g. set_complete was false), pass search_id back so it narrows within the cached set rather than re-running the search.

Cost saving

Because discovery is free and retrieval is charge-once, keeping your bill low is mostly about not paying twice for the same address — the API already does most of this for you.

Type freely — suggest is free

Autocomplete costs nothing, so debounce for speed, not for savings. There is no per-keystroke charge to optimise away.

Retrieve only on selection

The charge is on retrieve. Call it when a user actually picks a result — never speculatively for every suggestion on screen.

Charged once per address, per period

The first retrieve of a UPRN in the billing period is charged; every later retrieve of that same UPRN is free. Retries after a timeout do not double-charge.

Cache within the period

Store retrieved records and reuse them — re-fetching an address you already paid for this period is free, so caching is a latency win, not a billing necessity.

Filter locally when set_complete

When the response already holds the full match set, narrow it client-side. Fewer round-trips, lower latency.

Link the session with search_id

Carry search_id from suggest into retrieve so each charge is attributed to the search it came from — cleaner reporting in your usage dashboard.

Billing transparency headers

Every charged response tells you exactly what happened, so you can reconcile without guessing:
HeaderMeaning
X-Credits-ChargedCredits charged by this request (0 if already claimed this period).
X-Already-Chargedtrue when this UPRN was already paid for this period — no new charge.
X-Credits-BalanceYour remaining credit balance after the request.
const res = await vepler.search.searchRetrieve({ uprn, searchId });
// Read the transparency headers off the raw response when you need them:
//   X-Credits-Charged / X-Already-Charged / X-Credits-Balance
There is no session token to manage. An optional X-Search-Session request header (a UUID you generate per end-user search) is available purely to group a user’s requests together in your usage analytics — it does not affect billing. Billing deduplicates on the UPRN, on the server.

Rate limits

Suggest and retrieve are metered separately.

Suggest throughput

Free discovery is rate-limited per account (600 requests/minute by default). Over the limit returns 429 with a Retry-After header. Debouncing keeps you well under it.

Daily spend cap

Charged retrievals are also protected by a per-account daily credit cap, guarding against a runaway loop draining your balance.
Both limits are defaults and can be raised for your plan — see Usage & Limits for 429 handling and backoff, and contact us for higher limits.

FAQ

No. suggest is free — type-ahead on every keystroke does not run up a bill. You are charged only when you retrieve the full record for a chosen address.
Once. The first retrieval of a UPRN in the billing period is charged; the rest are recognised as already paid (X-Already-Charged: true) and cost nothing.
No. Billing is keyed on the address, not the request. A retry re-claims the same UPRN, sees it is already paid for the period, and is not charged again.
It’s optional but recommended. Passing it links the retrieval to the search it came from (cleaner reporting) and lets suggest narrow within a cached candidate set. Without it, retrieve still works as a direct resolve.
Discovery returns only what’s needed to display and pick a result — label, UPRN, and match highlights. Coordinates, postcode, and classification arrive in the (charged) retrieve step. This split is what keeps autocomplete free.
search_id is server-minted and links a suggest → retrieve flow for billing attribution. X-Search-Session is a client-generated header purely for grouping requests in your own usage analytics — it never affects what you are charged.

Next steps

Address Billing & Sessions

Why charge-once beats session tokens, and what it means for your invoices.

Usage & Limits

Rate limits, 429 handling, and backoff.

API Reference

Full request/response schemas for every search parameter.

Authentication

API keys and the x-api-key header.