SDSS endpoints
Run site suitability, Huff visit forecasts with cannibalization, catchment rings and multi-site comparisons through the API.
The spatial decision support endpoints run MapDove’s models on demand. They are POST endpoints with JSON bodies, require authentication and are relative to https://api.mapdove.com/api/v1.
Each successful call counts one analysis toward your monthly plan allowance (Free 25, Pro 500, Business and Enterprise unlimited), in addition to the API call. When the allowance is used up, calls return 402 plan_limit. Validation errors (400) do not consume an analysis.
| Method | Path | Purpose |
|---|---|---|
| POST | /sdss/suitability |
Weighted multi-criteria score for every hex, plus top sites |
| POST | /sdss/huff |
Visit forecast and cannibalization for a candidate |
| POST | /sdss/catchment |
Demographics in up to four rings |
| POST | /sdss/compare-sites |
Score, forecast and catchment for up to five sites |
Model details: Methodology.
Weights
Suitability and compare-sites take a Weights object. Every key is optional, each value is a number from 0 to 10, missing keys count as 0, and at least one weight must be positive. Unknown keys return 400.
{
"population": 5, "income": 3, "daytime": 8, "traffic": 6, "growth": 2,
"competition": 6, "complementary": 4, "rent": 3, "gap": 7
}
competition and rent are inverted criteria — a positive weight rewards less competition and lower rent. See Site suitability.
Suitability
POST /sdss/suitability
| Field | Type | Description |
|---|---|---|
city |
string | required city slug |
category |
string | required category slug |
weights |
Weights | required |
top_n |
integer | 1–50, default 10 |
min_spacing_km |
number | 0–50, default 1.5 |
curl -X POST https://api.mapdove.com/api/v1/sdss/suitability \
-H "Authorization: Bearer $MAPDOVE_KEY" \
-H "Content-Type: application/json" \
-d '{
"city": "austin",
"category": "coffee",
"weights": { "daytime": 8, "population": 5, "traffic": 6, "competition": 6, "gap": 7 },
"top_n": 5,
"min_spacing_km": 1.5
}'
const res = await fetch('https://api.mapdove.com/api/v1/sdss/suitability', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
city: 'austin',
category: 'coffee',
weights: { daytime: 8, population: 5, traffic: 6, competition: 6, gap: 7 },
top_n: 5,
}),
});
const { top_sites, stats } = await res.json();
body = {
"city": "austin",
"category": "coffee",
"weights": {"daytime": 8, "population": 5, "traffic": 6, "competition": 6, "gap": 7},
"top_n": 5,
}
r = requests.post(f"{BASE}/sdss/suitability", headers=H, json=body, timeout=120)
r.raise_for_status()
for s in r.json()["top_sites"]:
print(s["rank"], s["neighborhood"], s["score"])
Response (trimmed):
{
"geojson": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": { "type": "Polygon", "coordinates": ["…"] },
"properties": {
"hex_id": 77, "score": 72.4, "neighborhood": "Mueller",
"population": 0.612, "income": 0.544, "daytime": 0.701, "traffic": 0.655, "growth": 0.833,
"competition": 0.719, "complementary": 0.402, "rent": 0.488, "gap": 0.391
}
}
]
},
"top_sites": [
{
"rank": 1, "hex_id": 77, "score": 72.4, "lng": -97.7046, "lat": 30.2981, "neighborhood": "Mueller",
"criteria": {
"population": 0.612, "income": 0.544, "daytime": 0.701, "traffic": 0.655, "growth": 0.833,
"competition": 0.719, "complementary": 0.402, "rent": 0.488, "gap": 0.391, "competitors_nearby": 1.84
}
},
{ "rank": 2, "hex_id": 431, "score": 70.9, "lng": -97.7232, "lat": 30.3998, "neighborhood": "The Domain", "criteria": { "…": "…" } }
],
"stats": { "min": 11.8, "max": 72.4, "mean": 41.3 }
}
Criteria values are normalized 0–1 (already inverted for competition and rent). competitors_nearby is the raw distance-weighted competitor count.
Huff
POST /sdss/huff
| Field | Type | Description |
|---|---|---|
city |
string | required |
category |
string | required |
lng, lat |
number | required; must be inside the city’s study area |
sqft |
number | 100–2,000,000; default = category typical size |
brand_strength |
number | 0.6–1.5; default 1.0 |
curl -X POST https://api.mapdove.com/api/v1/sdss/huff \
-H "Authorization: Bearer $MAPDOVE_KEY" \
-H "Content-Type: application/json" \
-d '{ "city": "austin", "category": "coffee", "lng": -97.7046, "lat": 30.2981, "sqft": 2200, "brand_strength": 1.1 }'
const huff = await fetch('https://api.mapdove.com/api/v1/sdss/huff', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ city: 'austin', category: 'coffee', lng: -97.7046, lat: 30.2981, sqft: 2200, brand_strength: 1.1 }),
}).then((r) => r.json());
console.log(huff.predicted_daily_visits, huff.total_cannibalized_daily);
huff = requests.post(f"{BASE}/sdss/huff", headers=H, timeout=120, json={
"city": "austin", "category": "coffee", "lng": -97.7046, "lat": 30.2981,
"sqft": 2200, "brand_strength": 1.1,
}).json()
Response (trimmed):
{
"attractiveness": 1.26,
"calibration_k": 0.013812,
"predicted_daily_visits": 397,
"predicted_annual_visits": 145002,
"market_share": 0.0338,
"category_avg_daily_visits": 345,
"total_cannibalized_daily": 212,
"impacted": [
{
"place_id": 88, "name": "Bean Republic · Mueller", "lng": -97.6998, "lat": 30.2950,
"distance_km": 0.6, "current_daily_visits": 412, "delta_daily_visits": -58, "delta_pct": -14.08
}
],
"trade_area": {
"type": "FeatureCollection",
"features": [
{ "type": "Feature", "geometry": { "type": "Polygon", "coordinates": ["…"] }, "properties": { "hex_id": 77, "share": 0.07311 } }
]
}
}
impacted lists up to ten stores with the largest losses. trade_area covers the first 80% of the candidate’s modeled visitors. See Huff forecast and Cannibalization.
Catchment
POST /sdss/catchment
| Field | Type | Description |
|---|---|---|
lng, lat |
number | required |
rings |
number[] | required, 1–4 positive values; each radius ≤ 50 km after conversion |
unit |
string | km, mi or min |
min converts minutes to a radius with the average driving speed of the city containing the point — a radius approximation, not a road-network isochrone (Catchments).
curl -X POST https://api.mapdove.com/api/v1/sdss/catchment \
-H "Authorization: Bearer $MAPDOVE_KEY" \
-H "Content-Type: application/json" \
-d '{ "lng": -97.7046, "lat": 30.2981, "rings": [1, 3], "unit": "mi" }'
rings = requests.post(f"{BASE}/sdss/catchment", headers=H, timeout=60, json={
"lng": -97.7046, "lat": 30.2981, "rings": [5, 10, 15], "unit": "min",
}).json()["rings"]
for r in rings:
print(r["label"], r["radius_km"], r["population"])
Response (trimmed to one ring):
{
"rings": [
{
"label": "1 mi", "radius_km": 1.609344, "area_km2": 8.14,
"population": 18412, "households": 8120, "daytime_pop": 21904,
"median_income": 104300, "median_age": 34.8, "pct_college": 49.6, "pct_family": 33.1,
"traffic_index": 51.2, "rent_index": 58.7, "growth_rate": 0.0471,
"segments": [
{ "slug": "urban-professionals", "name": "Urban Professionals", "color": "#14b8a6", "population": 9021, "share": 0.49 },
{ "slug": "suburban-families", "name": "Suburban Families", "color": "#22c55e", "population": 4234, "share": 0.23 }
],
"places_by_category": [
{ "slug": "coffee", "name": "Coffee & Cafés", "color": "#92400e", "count": 4 },
{ "slug": "qsr", "name": "Quick Service", "color": "#dc2626", "count": 3 }
],
"geometry": { "type": "Polygon", "coordinates": ["…"] }
}
]
}
Compare sites
POST /sdss/compare-sites
| Field | Type | Description |
|---|---|---|
city |
string | required |
category |
string | required |
sites |
{ label, lng, lat }[] |
required, 1–5 sites |
weights |
Weights | suitability weights |
The whole comparison counts as one analysis. Each site’s Huff forecast uses the category’s typical size and brand strength 1.0, and omits trade_area. catchment_1 is a 1-mile ring in US cities and a 1.6 km ring in Dubai.
curl -X POST https://api.mapdove.com/api/v1/sdss/compare-sites \
-H "Authorization: Bearer $MAPDOVE_KEY" \
-H "Content-Type: application/json" \
-d '{
"city": "austin",
"category": "coffee",
"sites": [
{ "label": "Mueller", "lng": -97.7046, "lat": 30.2981 },
{ "label": "The Domain", "lng": -97.7232, "lat": 30.3998 }
],
"weights": { "daytime": 8, "population": 5, "traffic": 6, "competition": 6, "gap": 7 }
}'
const cmp = await fetch('https://api.mapdove.com/api/v1/sdss/compare-sites', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ city: 'austin', category: 'coffee', sites, weights }),
}).then((r) => r.json());
console.table(cmp.sites.map((s) => ({ site: s.label, score: s.score, visits: s.huff.predicted_daily_visits })));
Response (trimmed to one site):
{
"sites": [
{
"label": "Mueller", "lng": -97.7046, "lat": 30.2981,
"score": 72.4,
"criteria": { "population": 0.612, "daytime": 0.701, "competition": 0.719, "gap": 0.391, "…": "…" },
"huff": {
"attractiveness": 1.0, "calibration_k": 0.013812, "predicted_daily_visits": 352,
"predicted_annual_visits": 128480, "market_share": 0.0302, "category_avg_daily_visits": 345,
"total_cannibalized_daily": 188, "impacted": [ { "place_id": 88, "delta_daily_visits": -51, "…": "…" } ]
},
"catchment_1": { "label": "1 mi", "radius_km": 1.609344, "population": 18412, "…": "…" }
}
]
}
score and criteria come from the hex containing (or nearest to) the site; they are null if the point is not near any hex of the city grid.
Errors specific to SDSS
| Status | code |
Typical cause |
|---|---|---|
| 400 | bad_request |
Unknown criterion; weight outside 0–10; all weights zero; point outside the study area; sqft or brand_strength out of range; more than 4 rings or 5 sites; ring over 50 km; bad unit |
| 402 | plan_limit |
Monthly analyses used up |
| 404 | not_found |
Unknown city or category slug |