Overview
A serverless distance & duration API on the edge, response-compatible with the legacy Distance Matrix JSON shape. It returns scalar distance and duration — no route geometry, no turn-by-turn, no live traffic.
The public endpoint takes no key= and needs no signup. It’s rate-limited per IP. Forks that want private auth can re-introduce it in src/auth.ts.
Provided as-is, no warranty — built from public open data, no live traffic. Not for safety- or contract-critical use (emergency dispatch, regulated SLAs, legal billing). Use a commercial API there.
Endpoint
A single GET request. Origins and destinations are pipe-separated; each item is a lat,lng pair or an address string.
# no key — just GET it curl "https://open-distance.com/maps/api/distancematrix/json\ ?origins=Stanford,CA|Palo+Alto,CA\ &destinations=San+Francisco,CA|Oakland,CA\ &units=imperial"
Parameters
| Param | Required | Default | Notes |
|---|---|---|---|
| origins | yes | — | A|B|… — each a lat,lng pair or address string. |
| destinations | yes | — | C|D|… — same format as origins. |
| units | no | imperial | imperial or metric. |
| mode | no | driving | Only driving is supported; other modes are treated as driving. |
| key | no | — | Not required. Public endpoint is rate-limited per IP. |
Max 25 elements (origins × destinations) per request, or you’ll get MAX_ELEMENTS_EXCEEDED. place_id: inputs return NOT_FOUND.
Response shape
Byte-compatible with the legacy Distance Matrix JSON, plus a few additive fields (*_matches, data_version, copyrights) that old clients simply ignore.
{ "origin_addresses": ["Stanford, CA, USA", "Palo Alto, CA, USA"], "origin_matches": ["rooftop", "rooftop"], "destination_addresses": ["San Francisco, CA", "Oakland, CA"], "destination_matches": ["rooftop", "interpolated"], "data_version": "2026-06", "rows": [{ "elements": [{ "distance": { "text": "33.4 mi", "value": 53752 }, // metres "duration": { "text": "41 mins", "value": 2460 }, // seconds "status": "OK" }]}], "copyrights": "… ODbL §4.3 produced-work notice …", "status": "OK" }
Match tiers
Each origin and destination reports how confidently it was geocoded, in the origin_matches / destination_matches arrays.
- rooftopExact mapped point — authoritative rooftop dataset (NAD or OpenAddresses).
- interpolatedEstimated along a street segment (TIGER) — likely off ~30–100 m.
- coordsYou passed
lat,lngdirectly; no geocode performed. - "" (empty)Geocode failed; the address echoes your raw input.
Addresses that only resolve to a ZIP/city centroid (often miles off) return NOT_FOUND rather than a confidently-wrong distance.
Status codes
| Top-level status | Meaning |
|---|---|
| OK | Request valid; see per-element statuses. |
| INVALID_REQUEST | Missing or malformed parameters. |
| MAX_ELEMENTS_EXCEEDED | origins × destinations > 25. |
| OVER_QUERY_LIMIT | Per-IP rate limit hit (HTTP 429), or the account-wide daily cap hit (HTTP 503 + Retry-After). |
| REQUEST_DENIED | Request refused. |
Per-element status
| Element status | Meaning |
|---|---|
| OK | distance + duration returned. |
| NOT_FOUND | Origin or destination couldn’t be geocoded confidently. |
| ZERO_RESULTS | No route found between the snapped graph nodes. |
Rate limits
Per-IP on the hosted deployment. There is no paid tier — need more headroom? Self-host and tune the env vars (set any to 0 to disable that window). The daily budget is metered in elements (elements = origins × destinations — the same definition Google uses), so it tracks serving cost; the per-second tier is a request burst guard.
| Window | Limit | Env var |
|---|---|---|
| Second (burst) | 5 requests | RL_PER_SEC |
| Day | 2,500 elements | RL_ELEMENTS_PER_DAY |
On top of the per-IP limits there's an account-wide cap of 25,000 elements/day (resets at 00:00 UTC). If the whole service hits it you'll get HTTP 503 with a Retry-After header — not a 429. Need higher or dedicated limits? Email hello@open-distance.com for custom solutions.
Self-throttle headers
Every response — 200 and 429 alike — carries limit headers so you can back off without an extra probe. The -Day values are element budgets.
X-RateLimit-Limit-Second: 5 X-RateLimit-Remaining-Second: 4 X-RateLimit-Limit-Day: 2500 # elements X-RateLimit-Remaining-Day: 2499 # elements left today # on 429: Retry-After, X-RateLimit-Tier: sec | day
Deviations from the legacy API
It mirrors the legacy distance-matrix wire format, with intentional differences:
| Area | Difference |
|---|---|
| Auth | No key=. Rate-limited per IP instead. |
| Numbers | From a routed graph with no live traffic — they differ from traffic-aware providers. |
| Omitted fields | No fare, duration_in_traffic, geocoded_waypoints, or warnings. |
| Added fields | origin_matches, destination_matches, copyrights, data_version — all additive. |
| Modes | Driving only. place_id: inputs → NOT_FOUND. |
| Caching | Successful responses send Cache-Control: public, max-age=3600. |
Coverage
Continental US — the lower 48 states + DC. Short and medium routes go through a tiled local-road graph; long-haul routes (anything over ~620 mi straight-line) route through a national highway overlay layer that holds the country’s motorway network in a single binary. NYC→LA, Seattle→Miami, and Boston→Houston all return real answers.
Live coverage, sources, and supported behaviours are reported by the GET /coverage endpoint.
Self-hosting
The whole thing is fork-and-deploy on your own edge account for the entire continental US. The pipeline (fetch → build → upload → publish) is driven by one script.
The cost model is expensive once, then cheap for years: roughly ~$500 one-time for the full data load (it's write-heavy on D1), then ~$5/month hosting + ~$200/year maintenance to serve ~750k–1M queries/month — the entire lower-48. Honest about the nasty setup; the long horizon is cheap.
Full setup, data sources, and the refresh pipeline live in the repo. Read the self-host guide →