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
Service
Auth
Purpose
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.
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
suggestions
Suggestion[]
Predictions Google returned for this keystroke. Empty list if nothing matched.
Suggestion
place_id
string
Google place ID — pass this to GetPlace to resolve the full place.
text
string
Full single-line label ("Eiffel Tower, Paris, France").
primary_text
string
Main text, bolded in Google's own UI ("Eiffel Tower").
secondary_text
string
Secondary / context text ("Paris, France").
types
string[]
Place types — e.g. ["tourist_attraction", "point_of_interest"].
true when served from our cache (Google was not called). Use this to track your hit rate.
Place
Identity
place_id
string
Google place ID — primary key of the cache.
name
string
Display name ("Eiffel Tower").
Address
short_address
string
Condensed address.
formatted_address
string
Full Google-formatted address.
country
string
ISO-3166 short code — "US", "FR".
city
string
Locality / city.
postal_code
string
Postal / ZIP code.
Location
lat
double
Latitude.
lng
double
Longitude.
distance_meters
double
Great-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_type
string
Main Google type id — e.g. "restaurant".
primary_type_display
string
Human-readable form of primary_type.
types
string[]
All Google-assigned place types.
Ratings & pricing
rating
double
Average Google rating (0–5). Read alongside has_rating.
has_rating
bool
Distinguishes "no rating" from a literal 0.0.
user_rating_count
int64
Number of ratings. Read alongside has_user_rating_count.
Global Plus Code — short, address-free code for this place.
viewport_sw_lat
double
Viewport southwest corner latitude.
viewport_sw_lng
double
Viewport southwest corner longitude.
viewport_ne_lat
double
Viewport northeast corner latitude.
viewport_ne_lng
double
Viewport northeast corner longitude.
Photos
photos
Photo[]
In Google's order, the first is usually the best hero shot. Empty when Google returned none.
Opaque blobs
regular_opening_hours_json
bytes
Google's opening-hours object {openNow, periods[], weekdayDescriptions[]}, JSON-encoded. Empty when absent.
raw_json
bytes
Full raw Google response — kept verbatim so new Google fields don't require a re-fetch.
Photo
url
string
Keyless 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.
name
string
Google 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.
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
query
string
Required. A complete string, not a prefix.
origin_lat / origin_lng
double
Bias centre. See the note above; this is the field that decides which "Starbucks" you get.
radius_meters
double
Soft 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_code
string
Forwarded verbatim. Both are part of the server's cache key.
user_id
string
Opaque, stored for GetUsage.
Response
FindPlaceIDResponse
place_id
string
Google place ID of the best match.
place
Place
The 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_hit
bool
true 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.
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.
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 / lng
double
Required. The query point.
radius_meters
double
Shapes 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_types
string[]
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_results
int32
Caps the list. Zero uses 20, which is also Google's hard ceiling.
user_id
string
Opaque, stored for GetUsage.
Response
SearchNearbyResponse
places
Place[]
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_hit
bool
true when served entirely from the spatial cache with no Google call billed.
total_in_cell
int32
Candidates 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".
A coordinate to the most specific street address Google knows, returned as an address-levelplace_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.
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_id
string
Address-level Google place ID.
formatted_address
string
Full Google-formatted address.
types
string[]
Address types, e.g. ["street_address"].
lat / lng
double
Google's coordinate for the matched address, which is not your input coordinate.
cache_hit
bool
true 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 keycurl -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-usercurl -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_requests
int64
Count of matching request_logs rows.
autocomplete_calls
int64
Rows with method=autocomplete. Autocomplete only writes one row per session (not per keystroke).
get_place_calls
int64
Rows with method=get_place.
search_nearby_calls
int64
Rows with method=search_nearby.
find_place_id_calls
int64
Rows with method=find_place_id, cache hits included. Counted for visibility only: that SKU is free, so it never reaches a cost total.
cache_hits
int64
Rows served from the cache instead of Google, across every method that has one.
errors
int64
Rows with status=error.
total_sessions
int64
Distinct autocomplete_sessions rows in the filtered window.
Detail
entries
UsageEntry[]
request_logs rows, newest-first.
sessions
UsageSession[]
autocomplete_sessions rows, newest-first.
UsageEntry
id
string
Row UUID.
created_at
Timestamp
When the call landed.
method
string
"autocomplete", "get_place", "search_nearby", "find_place_id", or "reverse_geocode".
query
string
Whatever the call was keyed on: the typed string, the query string, or the place_id.
user_id
string
Whatever you sent on the original call. Empty if none was attached.
session_token
string
Stitches to sessions.
cache_hit
bool
Meaningful on every method except autocomplete, which is always forwarded to Google.
latency_ms
int32
Server-side latency.
status
string
"ok" / "error" / "not_found".
error
string
Truncated error message when status=error.
UsageSession
id
string
Row UUID.
started_at
Timestamp
First Autocomplete call of the session.
last_activity
Timestamp
Most recent call on this session.
ended_at
Timestamp
Set when a trailing GetPlace closed the session. Unset for abandoned sessions.
session_token
string
Join key to entries.
user_id
string
Latched from the first call that opened the session.
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.