cache_places/docs

A ConnectRPC proxy in front of Google Places. Every place_id we've seen before comes back from CockroachDB instead of Google — free, fast, deterministic.

Surfaces

ServiceAuthPurpose
RpcPlaces x-api-key header Resolve text or GPS to places; read your own usage
RpcApiKeys ?auth_password=… Admin: issue / list / revoke keys
Absent means zero. The JSON codec omits any field holding its zero value, so a false cache_hit does not arrive as false, it does not arrive at all. Same for 0 numbers and empty strings. Read them as "absent or falsy" rather than comparing strictly: in JavaScript resp.cacheHit === false is never true, it is undefined. Field names work in either snake_case or camelCase, in both directions.

Client — Autocomplete

POST /cache_places.pb_places.RpcPlaces/Autocomplete
curl -X POST "https://cache-places.dimitri.land/cache_places.pb_places.RpcPlaces/Autocomplete" \
  -H "x-api-key: $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"input": "sohm", "session_token": "<uuid>", "user_id": "user_123"}'
Always attach user_id when you have one. It's an opaque string — internal id, UUID, email, whatever identifies your end-user. Stored verbatim, returned by GetUsage, never forwarded to Google. Skipping it means you can't break your usage down per end-user later.

Response

AutocompleteResponse
suggestionsSuggestion[]Predictions Google returned for this keystroke. Empty list if nothing matched.
Suggestion
place_idstringGoogle place ID — pass this to GetPlace to resolve the full place.
textstringFull single-line label ("Eiffel Tower, Paris, France").
primary_textstringMain text, bolded in Google's own UI ("Eiffel Tower").
secondary_textstringSecondary / context text ("Paris, France").
typesstring[]Place types — e.g. ["tourist_attraction", "point_of_interest"].

Client — GetPlace

POST /cache_places.pb_places.RpcPlaces/GetPlace
curl -X POST "https://cache-places.dimitri.land/cache_places.pb_places.RpcPlaces/GetPlace" \
  -H "x-api-key: $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"place_id": "ChIJ...", "session_token": "<uuid>", "user_id": "user_123"}'

Response

GetPlaceResponse
placePlaceResolved place — schema below.
cache_hitbooltrue when served from our cache (Google was not called). Use this to track your hit rate.
Place
Identity
place_idstringGoogle place ID — primary key of the cache.
namestringDisplay name ("Eiffel Tower").
Address
short_addressstringCondensed address.
formatted_addressstringFull Google-formatted address.
countrystringISO-3166 short code — "US", "FR".
citystringLocality / city.
postal_codestringPostal / ZIP code.
Location
latdoubleLatitude.
lngdoubleLongitude.
distance_metersdoubleGreat-circle metres from the query point, computed server-side by PostGIS. Filled by SearchNearby, which has a point to measure from. Zero on GetPlace and FindPlaceID: they resolve one place by id, so there is no "from".
Type
primary_typestringMain Google type id — e.g. "restaurant".
primary_type_displaystringHuman-readable form of primary_type.
typesstring[]All Google-assigned place types.
Ratings & pricing
ratingdoubleAverage Google rating (0–5). Read alongside has_rating.
has_ratingboolDistinguishes "no rating" from a literal 0.0.
user_rating_countint64Number of ratings. Read alongside has_user_rating_count.
has_user_rating_countboolDistinguishes "no count" from a literal 0.
price_levelstring"PRICE_LEVEL_FREE" / "INEXPENSIVE" / "MODERATE" / "EXPENSIVE" / "VERY_EXPENSIVE".
price_range_jsonbytesNewer {startPrice, endPrice} object, JSON-encoded. Empty when absent.
Contact
website_uristringBusiness website.
google_maps_uristringCanonical maps.google.com URL.
national_phone_numberstringNational-format phone number.
international_phone_numberstringInternational-format phone number.
Status & time
business_statusstring"OPERATIONAL" / "CLOSED_TEMPORARILY" / "CLOSED_PERMANENTLY" / "BUSINESS_STATUS_UNSPECIFIED".
timezone_idstringIANA zone id ("America/New_York").
utc_offset_minutesint32Offset from UTC in minutes.
Map framing
plus_code_globalstringGlobal Plus Code — short, address-free code for this place.
viewport_sw_latdoubleViewport southwest corner latitude.
viewport_sw_lngdoubleViewport southwest corner longitude.
viewport_ne_latdoubleViewport northeast corner latitude.
viewport_ne_lngdoubleViewport northeast corner longitude.
Photos
photosPhoto[]In Google's order, the first is usually the best hero shot. Empty when Google returned none.
Opaque blobs
regular_opening_hours_jsonbytesGoogle's opening-hours object {openNow, periods[], weekdayDescriptions[]}, JSON-encoded. Empty when absent.
raw_jsonbytesFull raw Google response — kept verbatim so new Google fields don't require a re-fetch.
Photo
urlstringKeyless image URL served by this service, with your publishable ?pk= already baked in. Drop it straight into an <img src>; the private key never leaves the server. It already has a query string, so append a size as &w= (200 / 400 / 800 / 1600). Bare url defaults to 400px wide.
namestringGoogle photo resource name ("places/…/photos/…"). Durable and safe to store, but not an image URL on its own. Only useful if you hold your own Google key.

Note — bytes fields arrive as base64 strings over JSON. Decode, then JSON.parse.

Session dance — reuse the same session_token for every keystroke of an Autocomplete interaction and the trailing GetPlace. Google bills the whole thing as one lookup; a fresh token per keystroke is per-keystroke billing.

Client — FindPlaceID

POST /cache_places.pb_places.RpcPlaces/FindPlaceID

Resolve one complete query string to a single best-match place_id, or 404. This is the one-shot resolver for server-side work: enriching a list, resolving a name a model produced, deduping. Autocomplete is the other tool, for partial input with a human picking from the list.

curl -X POST "https://cache-places.dimitri.land/cache_places.pb_places.RpcPlaces/FindPlaceID" \
  -H "x-api-key: $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"query": "Eiffel Tower Paris", "origin_lat": 48.8566, "origin_lng": 2.3522, "radius_meters": 20000, "user_id": "user_123"}'
It is free. Every call, hit or miss. The server asks Google for the id and nothing else, which keeps it on the Text Search Essentials (IDs Only) SKU, which has no monthly cap. It mints no session. Sweep a whole list through it without costing anything out first.
Send an origin whenever you know roughly where the query is about. Without one, Google resolves the string against the caller's IP, and the caller is this server, in us-east1. Measured live: "Starbucks" returns three different places depending on whether you bias it to Paris, to New York, or not at all. Autocomplete survives that because the user sees the list and does not click. FindPlaceID hands back one id and nothing that says it is 6000km wrong.

Request

FindPlaceIDRequest
querystringRequired. A complete string, not a prefix.
origin_lat / origin_lngdoubleBias centre. See the note above; this is the field that decides which "Starbucks" you get.
radius_metersdoubleSoft bias radius. Never a filter: a better match outside the circle still wins, because Text Search accepts a circle only as a bias. Clamped to 50000. Requires an origin, sending it alone is a 400. Zero with an origin set biases at the 50000 ceiling.
region_code / language_codestringForwarded verbatim. Both are part of the server's cache key.
user_idstringOpaque, stored for GetUsage.

Response

FindPlaceIDResponse
place_idstringGoogle place ID of the best match.
placePlaceThe hydrated place, same schema as GetPlace above, present whenever the cache already holds that id. Free. Absent is the signal that matters: it means nobody has hydrated this id yet, and a follow-up GetPlace is the only call in this flow that costs anything. Branch on this, not on cache_hit.
cache_hitbooltrue when the resolution came from our cache instead of Google. A different cache from place, and they move independently: a phrasing nobody has sent before, naming a place we know well, gives cache_hit absent with place populated. Both are free.
404 is a real answer. The server tombstones a query Google matched nothing for, so asking again is a cache hit rather than another round trip. Do not retry on it.

Client — SearchNearby

POST /cache_places.pb_places.RpcPlaces/SearchNearby

A GPS point to the places Google sees there, closest first. Answered by a real spatial query (PostGIS ST_DWithin) against the shared place cache, not by a string key, so any reading inside the same building hits the same rows regardless of GPS jitter.

curl -X POST "https://cache-places.dimitri.land/cache_places.pb_places.RpcPlaces/SearchNearby" \
  -H "x-api-key: $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"lat": 48.8584, "lng": 2.2945, "included_types": ["restaurant"], "user_id": "user_123"}'
It returns candidates, not an answer. Deciding which of the places the user is actually at is your job, using signals we do not have: dwell time, opening hours, repeat visits. A paid lookup also harvests up to 20 places into the shared cache at no extra cost, so the next query near there is free for every tenant.

Request

SearchNearbyRequest
lat / lngdoubleRequired. The query point.
radius_metersdoubleShapes the spatial query, so a smaller value really does return fewer places. Values above 50 are clamped: that is the canonical Google fetch radius, and the cache does not reach further without paying again. Zero uses 50.
included_typesstring[]Keep only places whose types include any of these. Applied to the cached list after the query, so it never triggers a Google call.
max_resultsint32Caps the list. Zero uses 20, which is also Google's hard ceiling.
user_idstringOpaque, stored for GetUsage.

Response

SearchNearbyResponse
placesPlace[]Fully hydrated, same schema as GetPlace above, sorted nearest first with distance_meters filled. No follow-up call per place: the join is done server-side.
cache_hitbooltrue when served entirely from the spatial cache with no Google call billed.
total_in_cellint32Candidates the spatial query found before included_types and max_results narrowed the list. Useful for "I asked tightly, but there is more here if I loosen up".

Client — ReverseGeocode

POST /cache_places.pb_places.RpcPlaces/ReverseGeocode

A coordinate to the most specific street address Google knows, returned as an address-level place_id (types like street_address or premise). Not a business: use SearchNearby when you want the shop at that corner rather than the corner itself.

curl -X POST "https://cache-places.dimitri.land/cache_places.pb_places.RpcPlaces/ReverseGeocode" \
  -H "x-api-key: $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"lat": 48.8584, "lng": 2.2945, "user_id": "user_123"}'
This one is a different Google product. It forwards to the Geocoding API, not the Places API the other endpoints use. Cached on a snapped high-precision cell, and a coordinate Google cannot name (open water, the middle of a field) is tombstoned so we never pay for it twice.

Response

ReverseGeocodeResponse
place_idstringAddress-level Google place ID.
formatted_addressstringFull Google-formatted address.
typesstring[]Address types, e.g. ["street_address"].
lat / lngdoubleGoogle's coordinate for the matched address, which is not your input coordinate.
cache_hitbooltrue when served from our cache with no Google call.

Client — GetUsage

POST /cache_places.pb_places.RpcPlaces/GetUsage

Read back your own traffic. Scope is pinned to the x-api-key on the request — you can never observe another tenant's rows. Pass user_id to narrow to one of your end-users; omit it for everything under this key.

# All usage for this API key
curl -X POST "https://cache-places.dimitri.land/cache_places.pb_places.RpcPlaces/GetUsage" \
  -H "x-api-key: $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{}'

# Usage for one end-user
curl -X POST "https://cache-places.dimitri.land/cache_places.pb_places.RpcPlaces/GetUsage" \
  -H "x-api-key: $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"user_id": "user_123"}'

Response

GetUsageResponse
Aggregates (over the filtered window)
total_requestsint64Count of matching request_logs rows.
autocomplete_callsint64Rows with method=autocomplete. Autocomplete only writes one row per session (not per keystroke).
get_place_callsint64Rows with method=get_place.
search_nearby_callsint64Rows with method=search_nearby.
find_place_id_callsint64Rows with method=find_place_id, cache hits included. Counted for visibility only: that SKU is free, so it never reaches a cost total.
cache_hitsint64Rows served from the cache instead of Google, across every method that has one.
errorsint64Rows with status=error.
total_sessionsint64Distinct autocomplete_sessions rows in the filtered window.
Detail
entriesUsageEntry[]request_logs rows, newest-first.
sessionsUsageSession[]autocomplete_sessions rows, newest-first.
UsageEntry
idstringRow UUID.
created_atTimestampWhen the call landed.
methodstring"autocomplete", "get_place", "search_nearby", "find_place_id", or "reverse_geocode".
querystringWhatever the call was keyed on: the typed string, the query string, or the place_id.
user_idstringWhatever you sent on the original call. Empty if none was attached.
session_tokenstringStitches to sessions.
cache_hitboolMeaningful on every method except autocomplete, which is always forwarded to Google.
latency_msint32Server-side latency.
statusstring"ok" / "error" / "not_found".
errorstringTruncated error message when status=error.
UsageSession
idstringRow UUID.
started_atTimestampFirst Autocomplete call of the session.
last_activityTimestampMost recent call on this session.
ended_atTimestampSet when a trailing GetPlace closed the session. Unset for abandoned sessions.
session_tokenstringJoin key to entries.
user_idstringLatched from the first call that opened the session.
autocomplete_callsint32Keystroke count (rolled up).
selected_place_idstringplace_id the user eventually picked, if any.
last_inputstringLatest typed string seen on the session.

Admin — issue a key

POST /cache_places.pb_api_keys.RpcApiKeys/CreateApiKey
curl -X POST "https://cache-places.dimitri.land/cache_places.pb_api_keys.RpcApiKeys/CreateApiKey?auth_password=$ADMIN_PW" \
  -H 'Content-Type: application/json' \
  -d '{"name": "sidekick_api prod"}'

The response carries two values: key — the private bearer for the x-api-key header, keep it server-side — and public_key (cppk_…), a publishable token safe to embed in browser ?pk= photo-proxy URLs. It authorizes photo resolution only. Photo URLs returned by GetPlace already carry your pk.

Prefer a UI? Create, list, and revoke keys (and browse usage) from the password-gated admin dashboard at /admin.

Go SDK

import cache_places "github.com/ethanquix/autocomplete_places"

c := cache_places.New("https://cache-places.dimitri.land", os.Getenv("CACHE_PLACES_API_KEY"))

// Always pass WithUserID when you have one — unlocks per-user GetUsage.
s   := c.NewSession()
uid := cache_places.WithUserID("user_123")
hits, _ := s.Autocomplete(ctx, "sohm", uid)
res,  _ := s.GetPlace(ctx, hits[0].PlaceID, cache_places.WithGetPlaceUserID("user_123"))

// Complete string to a place_id, for free. Bias it when you can.
// res.Place often arrives populated, and then you are already done.
found, _ := c.FindPlaceID(ctx, "Eiffel Tower Paris",
    cache_places.WithFindBias(48.8566, 2.3522, 20000))

// GPS to the places Google sees there, closest first.
near, _ := c.SearchNearby(ctx, 48.8584, 2.2945, cache_places.WithSearchUserID("user_123"))

// Read your own usage back out.
u, _ := c.GetUsage(ctx, cache_places.WithUsageUserID("user_123"))
fmt.Println(u.TotalRequests, u.CacheHits)

Full integration guide on README.md. Copy-paste brief for AI agents at /prompt.