First-party geocoding: one engine, online and offline

# First-party geocoding: one engine, online and offline

> **Note, updated 3 August 2026.** Two things below have changed. The hosted gateway serves geocoding today, so it no longer answers `501`. And `GET /geocode` now also takes `bbox` as a hard bounding-box filter, honoured on both backends. The engine and the offline story are unchanged. See [/docs/api-reference](/docs/api-reference) for the current parameters.

MapMap geocodes from an engine we wrote. `GET /geocode` and `GET /geocode/reverse` are answered in-process by `sn-geocode`, a tantivy-backed index built by our own map factory, and billed at the Standard rate of 0.05p per call. It is the same index that ships inside signed territory packages, so the ranking the API hands back is the ranking the package carries. Point `SN_GEOCODE_DIR` at a territory's geocode index and the endpoints serve; with neither that nor `SN_PHOTON_URL` set they answer `501` with `urn:sn-gateway:problem:geocoding-not-enabled`, which is the hosted gateway's honest state until we wire a territory index into it.

## What the two endpoints take and return

Both are authenticated GETs, metered as one Standard call per request, answering with a GeoJSON `FeatureCollection` in Photon's property shape, so a client written against Photon works against us unchanged.

| Endpoint | Parameters |
|---|---|
| `GET /geocode` | `q` (required, free text), `limit` (1 to 10), `lang`, `bias` as `lon,lat` |
| `GET /geocode/reverse` | `lon`, `lat` (both required) |

```bash
export BASE=https://api.mapmap.ai
export API_KEY=snk_...

curl "$BASE/geocode?q=buckingham+palace&limit=3" \
-H "Authorization: Bearer $API_KEY"
```

```json
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": { "type": "Point", "coordinates": [-0.1419, 51.5014] },
"properties": {
"name": "Buckingham Palace",
"type": "address",
"countrycode": "GB",
"housenumber": "1",
"street": "The Mall",
"city": "London",
"postcode": "SW1A 1AA"
}
}
]
}
```

The `type` property is the document kind, one of `address`, `street`, `locality`, `poi` or `postcode`. Absent optionals stay absent rather than arriving as nulls, matching Photon. Two properties are ours rather than Photon's. `distance_m` is the rounded metres from your query point, and it appears on every reverse hit and on forward hits whenever you pass a `bias`. `categories` carries the category tokens the index stores for a POI, such as `fuel` or `truck_stop`, and it is omitted for everything that is not a POI. A missing `q`, or a malformed `lon`/`lat`, is a `400`; a backend failure is a `502`. The full parameter and error tables are in the [API reference](/docs/api-reference).

## Why Photon became the fallback rather than the engine

We demoted Photon for two reasons: it is a separate JVM service we would rather not ship, and corridor search needs an engine we own. Neither reason is that Photon is bad. We took Photon's document model outright: one flat document per addressable entity, carrying its own display fields plus its containing context, matched with an AND-style default operator and ranked by importance. That design is sound and we credit it in the source.

The service cost is the first reason. Photon is a JVM process with its own index to build, run, monitor and air-gap. Serving geocoding in-process removes a container from the Compose file, a runtime from the distro and a failure mode from the on-call rota, which matters most to the people running the stack themselves, as described in [sovereign navigation](/news/sovereign-navigation). First-party takes precedence when both backends are configured, and the `SN_PHOTON_URL` proxy stays supported for operators who prefer it, so nothing built against Photon has to move.

Corridor search is the second reason. A general geocoder does not rank results along a route, and we could not add that to one we did not own.

## How ranking works, and what a typo costs you

Retrieval runs up to three passes and stops as soon as it has enough candidates:

1. **Strict.** Every query token must match somewhere.
2. **Majority.** Only when strict found nothing: a majority of tokens must match, so one junk token cannot zero out your results.
3. **Fuzzy fallback.** Only when the exact passes gathered fewer candidates than your `limit`: a Levenshtein-1 pass over the full-word name fields.

Candidates are fetched by BM25 to a depth of four times `limit` (minimum 32), then re-ranked:

```text
score = bm25 × (1 + 0.5 × importance) × decay
decay = 1 / (1 + distance_m / 50_000) when bias is given, else 1
```

Inside each token, fields are boosted: name 3.0, housenumber 2.5, street 2.0, locality 2.0, region 1.2, and an edge-prefix field at 1.0 that makes partial trailing input like `Birmi` match as you type. A query shaped like a UK postcode adds a term on the raw postcode field boosted at 20.0, which strongly prefers postcode and address documents without excluding name matches, so an `M1` motorway POI can still surface below the `M1` postcode hits. `SW1A 1AA` and `sw1a1aa` build the identical query.

Typos cost you nothing and buy you nothing. Tokens under four characters are never fuzzed, because at two characters a one-edit neighbourhood matches almost anything, which is worse than no match. Fuzzy terms never touch the edge-prefix field. Hits found only by the fuzzy pass have their score multiplied by 0.15 **and** sort into a tier below every exact hit, so the guarantee holds independently of the arithmetic: `Newpork` returns the same first result as `Newport`, with a lower score, and `Rugby Trukstop` still finds Rugby Truckstop.

## Why "cashpoint" finds an ATM

Because drivers do not type canonical OSM category tokens. They ask for petrol, not `fuel`; a cashpoint, not `atm`; a lorry park, not `hgv_parking`. We ship a small table mapping colloquial, chiefly British phrases of up to four words onto the canonical category the index stores: `gas station` and `filling station` to `fuel`, `chippy` to `fast_food`, `hole in the wall` to `atm`, `car park` to `parking`.

The table is a plain const array in the code with no configuration file, so it ships identically in the hosted gateway and in an offline package. An expanded category term joins the query as an optional branch boosted at 0.5, deliberately below every text-field boost, so a synonym only ever broadens the result set or nudges the order. Something genuinely named after your query always beats a category match. It is a seed set, not a gazetteer, and extending it is one row and a rebuild.

## What corridor search does that a general geocoder will not

A corridor search keeps only results within `width_m` of a route polyline and orders them by how far along that polyline they lie. That is the question a driver actually has. Not "where is the nearest truck stop", which can be behind them, but "which truck stop is next". Hits carry `distance_from_route_m` and `distance_along_route_m`, and the along-route distance is the ordering key.

The semantics need saying plainly, because they are easy to get wrong. Results are ordered from the polyline's **first vertex**, so to get "nearest ahead of me" you pass the remaining route from your current position, not the whole planned route. The caller owns that trimming, because only the caller knows where the vehicle is. Widths are validated to a 10 m to 20 km band. Pruning ORs the per-segment bounding boxes together, capped at 64 boxes. A route with more segments than that collapses to a single overall box for pruning, and the exact great-circle width test still runs at re-rank, so the result set is identical either way, just gathered less selectively up front.

## How reverse geocoding resolves a point

By nearest-document lookup through an expanding candidate box rather than a fixed radius. The ladder is 250 m, 1 km, 5 km, 25 km, 100 km: each box is tried, and only widened when it holds fewer candidates than you asked for. Survivors are then ranked by exact haversine distance, so a dense urban lookup stays cheap while a point in open country still resolves against the largest box.

## What it costs

Geocoding bills at the Standard rate of 0.05p per call, forward and reverse alike, with no premium for the first-party engine over the Photon proxy.

| Item | Value |
|---|---|
| Price class | Standard |
| Rate | 0.05p per call, 0.03p beyond 1M calls/month, 0.02p beyond 10M |
| Metering | One call per request, forward and reverse alike |
| Free tier | 50,000 included standard calls per month after email verification |

## What it does not do

The biggest gap is that the hosted gateway does not serve geocoding at all yet, and the best feature in this post is not on the HTTP endpoint. The full list, because you will hit these:

- **Corridor search is not on the HTTP endpoint yet.** It is a `sn-geocode` crate API, available to self-hosters embedding the crate and to the offline path. `GET /geocode` today exposes `q`, `limit`, `lang` and `bias`, and nothing else.
- **`lang` is only honoured by the Photon proxy.** The first-party index accepts the parameter and ignores it, returning names as indexed.
- **Reverse returns at most five hits** on the gateway, and that number is not a parameter.
- **Postcode canonicalisation is UK-only.** Other countries' postcodes are ordinary text tokens.
- **In an extremely dense area the smallest reverse box can hold more documents than the candidate depth.** Every candidate is still inside that radius, so the nearest-first page is correct to within 250 m, not to the metre.
- **The hosted gateway answers `501` until we wire a territory index in.** Self-hosters have this today via `SN_GEOCODE_DIR`.

## Try it

Run it against your own deployment: point `SN_GEOCODE_DIR` at the geocode index inside a territory package and both endpoints come up in-process, no JVM, no search cluster. The [self-host guide](/docs/self-host) covers the Compose stack and the [API reference](/docs/api-reference) is authoritative on parameters and errors, though `GET /openapi.json` on your own gateway always wins.

Geocoding data derives from OpenStreetMap. Credit "© OpenStreetMap contributors" when you render or republish it.