ALL SYSTEMS · NOMINAL
UTC --:--:--
Docs·API reference·POST /v1/recommend-spot

Find the best spots in a region

Inverse query — give an activity, a region center, a radius, and a window. The engine returns the top-K ranked sub-spots in your catalog, sorted by physics + safety. The endpoint that powers "where should I go this weekend" surfaces inside booking apps and consumer products.

POSThttps://api.goable.io/v1/recommend-spot

When to use

/v1/score is a predicate lookup: "is THIS spot good?". You bring the location, the engine answers yes/no with a score. Use it when the user has already chosen where to go and you want to validate or display conditions.

/v1/recommend-spot is the inverse query: "WHERE should I go?". You bring an activity + a region, the engine enumerates every catalog sub-spot within radius, scores each independently, drops the unsafe ones, and returns the top-K ranked by effective score. Use it for discovery flows — "best kitesurf within 50km of Tarifa this weekend".

Internally the endpoint composes single-spot scoring across the spatial catalog, optionally folds in the personal blend when you supply a pseudonym, and writes one audit row per request to recommendation_runs. No new ML — just orchestration over layers you already know.

Request

{
 "activity": "kitesurfing",
 "regionCenter": { "lat": 36.013, "lng": -5.604 },
 "radiusKm": 50,
 "topK": 10,
 "window": {
 "from": "2026-07-02T09:00:00Z",
 "to": "2026-07-02T18:00:00Z"
 },
 "userPseudonym": "f9c8b…"
}

activity is the catalog slug (e.g. kitesurfing, surfing, ski-touring). regionCenter + radiusKm define the search circle. window is optional — defaults to next 24 hours. userPseudonym is optional and only used on Pro+ plans for personalization (see below).

Response

{
 "results": [
 {
 "rank": 1,
 "spotSlug": "kitesurfing-spot-tarifa-valdevaqueros",
 "name": "Tarifa — Valdevaqueros",
 "location": { "lat": 36.072, "lng": -5.696 },
 "distanceKm": 8.4,
 "score": 87,
 "effectiveScore": 87,
 "verdict": "favorable",
 "personalScore": null,
 "personalWeight": 0
 },

 ],
 "allGated": false,
 "totalCandidates": 12,
 "rankedCandidates": 10,
 "effectiveRadiusKm": 50,
 "effectiveTopK": 10,
 "personalizationApplied": false,
 "latencyMs": 312
}

results is sorted DESC by effectiveScore — within ±0.5 points the closer spot wins (distance tiebreak). totalCandidates counts every sub-spot the spatial resolver found in radius. rankedCandidates is the survivors after dropping hard-gated ones. effectiveScore equals score when no personalization applied, otherwise it's the blended value (see below).

Hard gates

Sub-spots with verdict: "unsafe" are dropped from results before ranking. The universal hard gates that trigger this are the same as for single scoring: lightning proximity ≥ 0.85, AQI category hazardous, severe storm. The dropped count is reported via the gap between totalCandidates and rankedCandidates.

When EVERY candidate in radius is hard-gated, the response returns allGated: true + results: []. This is semantically distinct from "no catalog spots in radius" (totalCandidates=0). Surface it in your UI as "no safe spots in this window" — never as a generic empty state.

Coverage hints

When results is empty AND allGated is false, the response carries an optional coverage object that distinguishes "no safe spots in window" from "we don't yet cover this region". Use it to give callers an actionable next step instead of a dead empty state. Two shapes:

// shape 1 — nearby spot exists; just expand the radius
{
 "results": [],
 "allGated": false,
 "totalCandidates": 0,
 "coverage": {
 "status": "no_subspots_in_radius",
 "nearestSubSpot": {
    "slug": "kitesurfing-spot-tarifa-balneario",
    "name": "Tarifa — Balneario",
    "distanceKm": 187.4
 },
 "suggestedAction": "expand_radius",
 "suggestedRadiusKm": 188
 }
}

// shape 2 — activity has zero sub-spots globally
{
 "results": [],
 "allGated": false,
 "totalCandidates": 0,
 "coverage": {
 "status": "no_subspots_for_activity",
 "suggestedAction": "request_coverage"
 }
}

Three response-handling shapes to wire into your client: (1) results.length > 0 — happy path, render the ranked list; (2) allGated: true — show "no safe spots in this window"; (3) coverage.status === "no_subspots_in_radius" — offer "try Xkm" with the suggested radius + nearestSubSpot.name; (4) coverage.status === "no_subspots_for_activity" — direct the caller to contact@goable.io or the open catalogue on GitHub. The score endpoint still works on any coordinate worldwide regardless.

Personalization (Pro+)

When userPseudonym is supplied AND your plan is Pro or Scale AND that user has logged ≥5 outcomes via the outcome endpoint, the engine folds in the per-user behavioural model. The blend weight ramps linearly from 0% at n=5 outcomes to capped at 50% at n=30+ — the physical calibrated score always carries the majority weight so discovery results stay anchored to objective conditions, not just user history.

Each result carries personalScore (the per-user model's prediction) and personalWeight (the actual cold-start blend weight applied, between 0 and 0.5). When personalization fires, the top-level personalizationApplied: true mirrors this.

Plan limits

Radius and topK are capped by plan: Free 25 km × topK 5 · Starter 50 km × topK 10 · Pro 200 km × topK 20 · Scale 1000 km × topK 50. Personalization is Pro+ only. Above the cap the API returns 402 PLAN_LIMIT_EXCEEDED with detail.maxKm or detail.maxTopK.

Webhook

Tenants subscribed to recommendation.completed receive an outgoing webhook after each successful call. Payload trims to the top 3 results for wire-envelope reasons (the full top-K stays in the audit DB). HMAC-SHA256 signed via X-Goable-Signature as with every other outgoing event — see the webhooks reference.

Errors

402 PLAN_LIMIT_EXCEEDED — radius or topK above plan cap (see detail). 422 VALIDATION_ERROR — malformed body or missing fields. 422 ACTIVITY_NOT_FOUND — activity slug not in the catalog. 401 UNAUTHORIZED — missing or invalid bearer token. 503 SERVICE_UNAVAILABLE — the engine's spatial resolver isn't wired (deployment misconfiguration, not a caller problem).