POST /v1/proximity
Drive-time distance, nearest-candidate, proximity screening, and labor-shed compute across multiple US + Canada coordinates.
/v1/compute in early design docs. The endpoint
was renamed to /v1/proximity before its first public release, so there is no
/v1/compute to migrate from — nothing ever served under that path. If you
find compute in our module names or internal symbols, that is the
implementation package, not an alternate endpoint.When to use this
/v1/fetch and /v1/ask answer questions about one coordinate.
/v1/proximity answers questions about the relationship between coordinates
— how far, how long, which is closest, which sites clear a drive-time bar,
how many workers live within a commute. It’s a matrix operation, not a
per-point lookup.
One request body, discriminated on op:
paid_driving_calcs (exactly what
pricing charged for — see Pricing) and notes (the honesty labels
below). Errors share one taxonomy — see Errors.
Coverage and honesty labels
- US + Canada. Every response’s
notesincludes"coverage: US + Canada", regardless of op or mode. - Durations reflect typical traffic, not real-time conditions. Every
response that actually drives — every
screen/labor_shedcall, and anydistance/nearestcall withmode: "driving"— adds"durations reflect typical traffic, not real-time"tonotes. Don’t use this for “how long right now”; use it for “how long, typically.” mode: "straightline"(available ondistanceandnearestonly) never calls a routing provider — it’s a pure geodesic (great-circle) distance, computed locally, free.duration_seconds/duration_minutesare alwaysnullin this mode (no traffic note either, since nothing was timed), because there’s no route to time.
Locators: a coordinate or a street address — never a place name
Every origin, destination, and anchor is either a"lat,lng" string or a US
street address. It is never a place name. “JFK Airport” and “the Tesla
Gigafactory” are not addresses, and passing one resolves as an ordinary
(unresolvable) address string — it does not fall back to a search.
To reach named infrastructure, use nearest’s curated set parameter
(@airports, @substations, @power_plants, @rail, @ports,
@urban_areas — see nearest) or supply
that place’s own coordinate directly.
This is deliberate, not a missing feature: Geocodio’s distance API only ever
receives coordinates that /v1/proximity has already resolved server-side —
never a caller’s raw locator string. That’s what keeps one bad address from
failing an entire N×M matrix (a single bad geocode used to be able to 422 the
whole request), what lets the same accuracy gate /v1/geocode enforces apply
uniformly here, and why a POI name can’t quietly resolve to whatever a text
search happens to guess.
Be as precise as you can — vague locators fail quietly upstream
The failure mode to design against is not “no match found.” It’s a confident match on the wrong place. Two real examples:"SFO airport" comes back at confidence
1.0. There is no second candidate to compare against — asking for five
returns exactly one. The only thing standing between that and a 2,400-mile
drive time reported as fact is our accuracy gate, which refuses both because
they resolved to place tier rather than to a street or a rooftop.
So the gate saves you, but only from the coarse cases. Give it as much as you
have:
- Always include city + state, or a ZIP. A bare street line is the single most common cause of a wrong match — Market Streets and Main Streets exist in hundreds of towns, and the geocoder will happily pick one.
- Never send a landmark, business, or airport name. Use its coordinate, or
nearest’s curatedset. - Prefer coordinates for anything you resolve repeatedly. They skip the gate, cost no geocoding credit, and can’t drift.
- A gate pass is not a correctness guarantee. US territories are a known
hole: a Puerto Rico address can match a different PR address at rooftop
tier and 0.99 confidence. Check
formatted_addressin the response against what you sent whenever the stakes are high.
The accuracy gate
An address resolves through the same floor/v1/geocode enforces — below
parcel/street-tier precision or below 0.8 similarity, it’s refused rather than
guessed. Every locator’s outcome is echoed back as a ResolvedPoint:
"1450 Ridgecrest Dr, CA" names a
street and a state but no city, and the upstream doesn’t report no match — it
silently returns the centroid of the city of Ridgecrest, which is a real
place hundreds of miles from most streets of that name:
"lat,lng" to gate on.
Per-item failures never fail the whole request — except for a required role
Adistance/screen request’s bulk destinations degrade per-item: a
failed one is omitted from legs, everything else still computes, and each
one’s outcome is echoed in resolved_destinations. nearest’s candidates
degrade too, but with less detail — they are excluded from the ranking and
counted in notes, with no per-candidate echo. paid_driving_calcs is
still priced from the full request shape, not from what happened to resolve —
see Pricing for why that matters.
A required role — every op’s origin(s), or screen’s anchors — fails the
whole request with 422 unresolvable_input if every item in that role fails.
distance — N origins × M destinations
"JFK Airport" is a place name, not an address — see
Locators.
It resolves as an ordinary unresolvable address string:
legs has one entry (the destination that resolved), but
paid_driving_calcs is 2 — one origin × two destinations, the request’s
shape, computed before either locator was resolved. You’re charged for what
you asked for, not for what happened to work.
The snap guard
A driven leg shorter than 95% of the straight-line distance between the same two points is not a real road route — it’s the provider silently snapping an unreachable point (an island with no bridge, a spot in open water) to the nearest road. This is a real, verified case: Los Angeles to Catalina Island — no road or bridge crosses that water, and the routing provider still returns a confident 200 with a “drive.”/v1/proximity computes
the straight-line distance locally for every leg and flags this as
impossible rather than passing that through:
duration_seconds/duration_minutes are nulled,
because a duration for a route that doesn’t exist isn’t a number worth
trusting. (Real routes legitimately run longer than straight-line — a
15–20% detour factor is normal — so the guard only fires on the geometrically
impossible direction.)
nearest — top-N from a curated set
"origin_name"-style search — set names one of six curated
destination sets, each backed by a public federal dataset already ingested
into the catalog:
- The search radius is fixed at 160 km (~100 mi) and isn’t configurable in
v1. If nothing qualifies within that radius,
candidatescomes back empty — not an error. applied_filtersechoes exactly what you sent, not the default that ran when you sent nothing. If you call@substationswith nofilters, the 115 kV floor above still applies, butapplied_filtersreadsnull. Passfiltersexplicitly if you need the response to say what threshold was used.
paid_driving_calcs for nearest is min(25, n × 5) — a fixed multiple of
n, not the number of candidates actually found or returned, so it’s
knowable before the call.
screen — proximity filter against up to 10 anchors
Filters a batch of origins by drive-time proximity to any of up to 10
anchors — e.g., “which of these 40 parcels are within 20 minutes of I-95’s
Exit 12 (40.71,-73.99) AND at least 10 minutes from downtown.” Anchors are
"lat,lng" coordinates or street addresses, the same as every other locator
in this API — screen has no curated set parameter (that’s nearest’s),
so there is no shortcut for “the nearest interstate on-ramp”; supply the
on-ramp’s own coordinate.
screened_out reports each
origin’s own best duration against ANY anchor, even though it missed the
band — that’s the near-miss, and it’s the whole reason max_minutes/
min_minutes are enforced locally rather than sent to the routing provider:
an upstream duration filter makes a failing leg vanish with no marker (and
still bills for it), which is unusable for “how close did it come.”
best_duration_seconds: null (the third origin above) means every anchor
leg was unreachable or snapped — not just slow.
min_minutes is a lower bound — use it to exclude an origin that’s too
close to every anchor (e.g., you want 10–30 minutes from a highway, not right
on top of it). screen always drives; there is no straightline mode, since
“screen by proximity” without traffic-aware duration isn’t the use case.
paid_driving_calcs = len(origins) × len(anchors) — the full matrix, always
computed, regardless of how many survive.
labor_shed — civilian labor force + population within a drive time
origin within minutes of driving. Rather than routing every tract in the
country, it classifies each candidate tract by pure geometry first, which is
free:
- Definitely reachable — the tract’s straight-line distance is under a 20 mph bound. No real route can be slower than a straight line at that speed over that short a distance, so it counts without a routed check.
- Definitely unreachable — straight-line distance exceeds a 75 mph bound. No road beats a straight line, so it’s excluded without a routed check.
- The annulus — everything between those two bounds is genuinely
uncertain and gets one real driving-matrix call per tract centroid.
tracts_matrix_queriedis exactly this count, and it ispaid_driving_calcsforlabor_shed— you’re charged for the tracts that actually needed a routed answer, not the ones geometry already settled.
/v1/proximity driving op applies: a leg shorter than 95% of its
straight-line distance is a snap-to-nearest-road artifact (an island tract
snapped to a nearby mainland road, say), not a real route — even when its
reported duration looks in-budget. A flagged tract is excluded from both
sums and from tracts_counted, but it’s still counted in
tracts_matrix_queried (it was still queried) and in tracts_unreachable,
so a trimmed shed is visible rather than silently over-counted.
A labor_shed query over an entirely near or entirely far origin — a small
minutes in a sparse area, say — can cost zero paid driving calcs and
still bill the 25-credit floor. The annulus is capped at 3,000 tracts;
past that, the request fails loud with 422 shed_too_large rather than
running an enormous, slow matrix.
civilian_labor_force skips any tract whose ACS estimate is null (a sample
the Census Bureau couldn’t produce contributes nothing); population counts
every included tract regardless — a tract with unknown labor force still has
known people living in it.
Just want your own tract’s numbers, not a shed?
The same two figures are also ordinary catalog fields —tract_civilian_labor_force
and tract_population — fetchable for a single point via /v1/fetch, at the
standard per-field credit price, with no /v1/proximity call needed:
/v1/fetch when you want the home tract’s own figures; reach for
labor_shed when you want the total across everywhere reachable within a
drive time, which is almost never just the home tract.
Pricing
Every response echoespaid_driving_calcs — priced from the request
shape (or, for labor_shed, the annulus size after the free geometric
prefilter), never from how many locators happened to resolve, so the price is
always knowable in advance:
labor_shed doesn’t undercharge for the fixed cost of running it:
"lat,lng" coordinate triggers its own forward-geocoding
call against the same shared quota POST /v1/geocode draws from, so it’s
priced at that same rate: +1 credit per address-form locator, on top of
the driving-calc price above — a coordinate locator never adds anything.
Capped at 25 address-form locators per request (across
origins+destinations, or origins+anchors on screen) — a request over that
cap is rejected 422 before any billing, same as the total-calc cap below.
Worked examples:
/v1/meta/plans publishes the constants above
(proximity_per_driving_calc, proximity_distance_min, proximity_nearest_min,
proximity_screen_min, proximity_labor_shed_min, proximity_per_address_locator)
alongside your plan’s credit-to-dollar rate.
Which failures are billed. The price is debited in two parts, and there
are no refunds, so what a failure costs depends on which parts it reached:
- the geocoding part (
+1per address-form locator) is debited before your locators are resolved, because resolving them is what makes those calls; - the driving part (
max(op_floor, 12 × paid_driving_calcs)) is debited as late as it safely can be: after every required locator has resolved, after the curated set has been looked up, and after the per-request budget share has been checked — but still before the driving matrix is called.
Errors
All errors use the standard{"detail": {"error", "message", "retryable"}}
shape (see Errors).
Diagnosing unresolvable_input
By far the most common way to hold this endpoint wrong is to name a place
rather than an address — "detroit", or a bare street like
"1820 meadowbrook circle" (whose street type matches the town of Circle,
Montana). Those don’t fail loudly at the geocoder: they match
something, just not a place specific enough to route from. The accuracy gate
refuses them rather than answering about the wrong location, and the 422
tells you exactly what it matched so you can fix it in one edit:
role— which required role failed:origin, oranchoronscreen. Only one role is reported; a request can fail on either.index— position in the list you sent, so you can map a failure back to the locator that caused it.error—low_confidence_resolution(matched, but too coarse to trust — the usual case),unresolvable_input(no match at all), orgeocoding_failed(the lookup itself failed; worth a retry).matched_address/accuracy_type— what the geocoder actually landed on, and how precisely. No coordinate is echoed: the gate refused it, so returning it would invite you to use a point we don’t trust.unresolved_countis exact;unresolvedis capped at 10 entries so a bulk request returns a readable sample rather than one entry per locator.caller_guidance— the disclosure rule above, carried on the error so an agent reads it at the moment it’s about to retry. On the MCP surface the same key appears alongsideunresolved_accuracy_types(the tiers only; the addresses stay off that surface, see the note below).
matched_address and query appear in the response body only —
never in message, which is retained in our request telemetry.
If the ambiguity is the point of your question — you have a name and want to
find out which place it means — use POST /v1/lookup
first. It returns ranked candidates and an explicit clarify disposition
instead of picking one, then feed the coordinate it gives you into
/v1/proximity. That’s also the pattern that scales: resolving 40 candidate
sites once and computing over coordinates is cheaper and more predictable than
re-geocoding them on every call.
Limits
- Sync only. No batch/async job endpoint for
/v1/proximityin v1 — every request completes in the response. - Per-request caps:
distance/screenorigins ≤ 500,distancedestinations ≤ 500,screenanchors ≤ 10,nearestn≤ 25,labor_shedminutes5–90, annulus ≤ 3,000 tracts. On top of those,distance’s origins × destinations (andscreen’s origins × anchors) must not exceed 10,000 — Geocodio’s synchronous distance-matrix limit — and no more than 25 address-form locators (see Pricing) may appear in one request. Both reject422 invalid_requestin the standard error object — the breached cap and the advice to split are inmessage, and pydantic’s per-field detail is inerrors. These two are rejected before the handler runs, so they are not billed (see Pricing for the ones that are). - Per-request share of the shared driving budget: 3,500 driving calcs.
Separate from the caps above and enforced against the actual upstream
spend, after the 30-day memo has been consulted — so a request whose legs
are already memoized can exceed 3,500 total calcs and still pass, because
it buys nothing new. A request that would newly reserve more than that
rejects
422 proximity_request_exceeds_budget_sharebefore any upstream call, naming the ceiling and how to shrink the request. It is never silently truncated, and it is not billed — the check runs before the debit (see the billing table under Pricing). The ceiling clearslabor_shed’s 3,000-tract annulus cap, so a shed at the documented maximum always fits. This exists so no single caller can drain the shared monthly budget and take/v1/proximitydown for everyone else; the message is explicit that the pool still has room. - A driving leg is memoized for 30 days, keyed on the rounded coordinate pair — a repeat lookup of the same origin/destination within the window is served from the memo and still bills at the same rate (the memo is margin, not a discount; pricing is charged from the request shape either way). A failed lookup is never memoized, so a transient outage can’t freeze a wrong answer for a month.
- Coverage is US + Canada, driving only — every driving response says so
in
notes.
Body
- DistanceOp
- NearestOp
- ScreenOp
- LaborShedOp
N origins x M destinations, driving or straightline.
"distance"1 - 500 elements1 - 500 elementsdriving, straightline Reserved for a future single-unit rendering. Leg always returns BOTH distance_miles and distance_km regardless of this value -- no behavior currently depends on it.
miles, km Refuse this request if it would cost more than this many credits. Defaults to the service ceiling (see MIREYE_PROXIMITY_CREDIT_CEILING); raise it to opt into an expensive request, lower it to cap your own spend. The refusal is a 422 that states the exact price, and it happens BEFORE the driving matrix is charged.
1 <= x <= 200000Response
Successful Response