> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vepler.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Search & Autocomplete

> Build fast, accurate UK address autocomplete with Vepler — the free suggest → charged retrieve flow, every tuning lever, and how to keep your bill low.

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

<Card title="Discovery is free. Retrieval is charged once per address." icon="circle-check">
  Call [`/v1/search/suggest`](#step-1-suggest-free-discovery) as freely as your rate limits allow —
  autocomplete on every keystroke costs nothing. The charge lands only on
  [`/v1/search/retrieve`](#step-2-retrieve-charged-selection), and the **same address in the same
  billing period is charged once**, no matter how many times you fetch it.
</Card>

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.

<Info>
  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](/guides/address-billing).
</Info>

***

## Quick start

Two calls: surface suggestions as the user types, then retrieve the one they choose.

<CodeGroup>
  ```typescript SDK theme={null}
  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);
  ```

  ```bash cURL theme={null}
  # 1. Free discovery
  curl -G "https://api.vepler.com/v1/search/suggest" \
    -H "x-api-key: $VEPLER_API_KEY" \
    --data-urlencode "q=10 Downing"

  # 2. Charged retrieval of the chosen UPRN
  curl -G "https://api.vepler.com/v1/search/retrieve" \
    -H "x-api-key: $VEPLER_API_KEY" \
    --data-urlencode "uprn=100023336956" \
    --data-urlencode "search_id=3f8a2c1e-9b7d-4e6f-8a2c-1e9b7d4e6f8a"
  ```
</CodeGroup>

<Note>
  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.
</Note>

***

## 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

<ParamField query="q" type="string" required>
  The search query. Anything from a partial postcode to a full address line.
</ParamField>

<ParamField query="source" type="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.
</ParamField>

<ParamField query="classification" type="string" default="residential">
  `residential`, `commercial`, or `all`. Filters address results by property class.
</ParamField>

<ParamField query="granularity" type="string" default="address">
  `address` (one row per property) or `street` (one row per named street / USRN — useful for
  early keystrokes and street-level pickers).
</ParamField>

<ParamField query="addressable_only" type="boolean" default="false">
  When `true`, excludes historical and superseded addresses — only live, addressable records.
</ParamField>

<ParamField query="lat" type="number">
  Latitude (WGS84, −90 to 90) of a centre point to make results proximity-aware. Must be paired
  with `lng`.
</ParamField>

<ParamField query="lng" type="number">
  Longitude (WGS84, −180 to 180). Must be paired with `lat`.
</ParamField>

<ParamField query="radius_meters" type="integer" default="10000">
  Radius around the centre point (1–200,000 m). Required when `geo_mode=restrict`.
</ParamField>

<ParamField query="geo_mode" type="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](#proximity-bias-vs-restrict).
</ParamField>

<ParamField query="limit" type="integer" default="10">
  Number of results, 1–100.
</ParamField>

<ParamField query="offset" type="integer" default="0">
  Pagination offset (max 10,000).
</ParamField>

<ParamField query="search_id" type="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](#candidate-sets-narrow-without-re-fetching).
</ParamField>

### Response

The response is a standard list envelope. Address rows are the slim discovery projection:

```json theme={null}
{
  "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" }
}
```

<ResponseField name="data" type="array">
  The suggestions. `type` is `address`, `postcode`, `location`, etc.; address rows carry `uprn`.
</ResponseField>

<ResponseField name="search_id" type="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.
</ResponseField>

<ResponseField name="set_complete" type="boolean">
  `true` when `data` already holds the **complete** match set — you can filter further keystrokes
  locally instead of calling the API again.
</ResponseField>

<ResponseField name="has_more" type="boolean">
  Whether more results exist beyond this page (`limit`/`offset`).
</ResponseField>

<Note>
  Through the SDK, the same fields are camelCased — `searchId`, `setComplete`, `hasMore`,
  `totalCount`, `mainText`, `matchedSubstrings`.
</Note>

***

## 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

<ParamField query="uprn" type="string" required>
  The `uprn` of the chosen suggestion. Its full record is returned and charged.
</ParamField>

<ParamField query="search_id" type="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.
</ParamField>

### Response

```json theme={null}
{
  "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"
}
```

| Status | Meaning                                                                 |
| ------ | ----------------------------------------------------------------------- |
| `200`  | Full record returned (and charged, unless already claimed this period). |
| `400`  | Invalid `uprn` or `search_id`.                                          |
| `404`  | UPRN 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**.

<Steps>
  <Step title="Debounce keystrokes (~300ms)">
    Wait for a short pause in typing before firing. One request per settled query instead of one
    per character.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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).
  </Step>

  <Step title="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.
  </Step>
</Steps>

```typescript theme={null}
// 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);
}
```

<Tip>
  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.
</Tip>

***

## 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:

<CardGroup cols={2}>
  <Card title="geo_mode=bias" icon="magnet">
    **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.
  </Card>

  <Card title="geo_mode=restrict" icon="crosshairs">
    **Hard cutoff.** Only address results within `radius_meters` of the point are returned. Best
    for "search within this area" experiences. Requires `radius_meters`.
  </Card>
</CardGroup>

```typescript theme={null}
// "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:

<Steps>
  <Step title="First real query returns a candidate set">
    The response includes `search_id`. If `set_complete` is `true`, `data` is the **entire**
    match set.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

***

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

<CardGroup cols={2}>
  <Card title="Type freely — suggest is free" icon="keyboard">
    Autocomplete costs nothing, so debounce for *speed*, not for savings. There is no per-keystroke
    charge to optimise away.
  </Card>

  <Card title="Retrieve only on selection" icon="hand-pointer">
    The charge is on `retrieve`. Call it when a user actually picks a result — never speculatively
    for every suggestion on screen.
  </Card>

  <Card title="Charged once per address, per period" icon="receipt">
    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.
  </Card>

  <Card title="Cache within the period" icon="database">
    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.
  </Card>

  <Card title="Filter locally when set_complete" icon="filter">
    When the response already holds the full match set, narrow it client-side. Fewer round-trips,
    lower latency.
  </Card>

  <Card title="Link the session with search_id" icon="link">
    Carry `search_id` from suggest into retrieve so each charge is attributed to the search it came
    from — cleaner reporting in your usage dashboard.
  </Card>
</CardGroup>

### Billing transparency headers

Every charged response tells you exactly what happened, so you can reconcile without guessing:

| Header              | Meaning                                                                   |
| ------------------- | ------------------------------------------------------------------------- |
| `X-Credits-Charged` | Credits charged by **this** request (`0` if already claimed this period). |
| `X-Already-Charged` | `true` when this UPRN was already paid for this period — no new charge.   |
| `X-Credits-Balance` | Your remaining credit balance after the request.                          |

```typescript theme={null}
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
```

<Note>
  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.
</Note>

***

## Rate limits

Suggest and retrieve are metered separately.

<CardGroup cols={2}>
  <Card title="Suggest throughput" icon="gauge-high">
    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.
  </Card>

  <Card title="Daily spend cap" icon="shield">
    Charged retrievals are also protected by a per-account daily credit cap, guarding against a
    runaway loop draining your balance.
  </Card>
</CardGroup>

Both limits are defaults and can be raised for your plan — see [Usage & Limits](/guides/rate-limiting)
for `429` handling and backoff, and [contact us](mailto:sales@vepler.com) for higher limits.

***

## FAQ

<AccordionGroup>
  <Accordion title="Do autocomplete keystrokes cost anything?">
    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.
  </Accordion>

  <Accordion title="If I retrieve the same address ten times this month, what am I charged?">
    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.
  </Accordion>

  <Accordion title="Does a retry after a network timeout double-charge me?">
    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.
  </Accordion>

  <Accordion title="Do I need to send search_id?">
    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.
  </Accordion>

  <Accordion title="Why don't suggestions include coordinates?">
    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.
  </Accordion>

  <Accordion title="What's the difference between search_id and X-Search-Session?">
    `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.
  </Accordion>
</AccordionGroup>

***

## Next steps

<CardGroup cols={2}>
  <Card title="Address Billing & Sessions" icon="receipt" href="/guides/address-billing">
    Why charge-once beats session tokens, and what it means for your invoices.
  </Card>

  <Card title="Usage & Limits" icon="gauge" href="/guides/rate-limiting">
    Rate limits, `429` handling, and backoff.
  </Card>

  <Card title="API Reference" icon="code" href="https://api.vepler.com/reference">
    Full request/response schemas for every search parameter.
  </Card>

  <Card title="Authentication" icon="lock" href="/authentication">
    API keys and the `x-api-key` header.
  </Card>
</CardGroup>
