Get Location
Get full details for a specific location
Endpoint
Description
A location is an overnight stop — a marina, harbour, or anchorage where you can berth or drop anchor and spend the night aboard your boat.
Retrieve full details for a location (marina, anchorage, bay), including the boats listed there (paginated), nearby points of interest grouped by category, images, amenities, references, and neighboring locations. The response spreads the location row, so many enrichment columns (contact info, facilities flags) come back alongside the computed fields below. Raw source blobs are stripped and never returned: osm_tags, google_data, ais_hours, ais_stats and piers (their useful parts arrive shaped, as busyness, ais, ais_passages and ais_piers).
Responses are cached for 5 minutes.
Authentication
Send X-API-Key: YOUR_API_KEY on every call from outside charter.boats — without it Cloudflare bot protection answers with an HTML 403 page, not JSON. Call it from your server: this route sends no CORS headers (only the exact GET /api/locations index does). See Authentication.
Path Parameters
| Parameter | Type | Description |
|---|---|---|
id | number|string | Location ID (e.g. 11336) or slug (e.g. d-marin-lefkas). Both resolve to the same row; the slug is canonical |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
page | number | No | Page of the boats[] listing (default: 1, 24 boats per page). Every other field is the same on every page |
Example Request
Example Response
The response spreads the location row (truncated below for readability) and appends the computed fields images, boats, boat_count, listing_total, min_boat_price, page, page_size, total_pages, pois_by_category, poi_count, references, amenities, nearby_locations, geo_crumbs, busyness, ais, ais_passages and ais_piers.
Response Fields
| Field | Type | Description |
|---|---|---|
id | number | Location ID |
slug | string|null | URL slug — the canonical key (/locations/{slug} on the site) |
name | string | Display name — the place's own local name, never a translation of it (Cala di Volpe, not "Fox Cove"). A name in a non-Latin script is given in Latin letters (Όρμος Γαρίτσας → Garitsa Bay), and a name that does not say what the place is carries its type (Cagliari Harbour). Never null: a place with no name of its own carries its type label (e.g. Anchorage) |
name_en | null | Always null — name is the one name to show |
lat | number | Latitude |
lon | number | Longitude |
city | string|null | City |
state | string|null | State or region |
country | string|null | Country |
country_code | string|null | ISO country code (lowercase, e.g. gr) |
harbour_category | string|null | Type of harbour |
description | string|null | Location description (markdown) |
image_url | string|null | Primary image URL |
image_source | string|null | Attribution label for image_url |
rating | number|null | Average rating |
rating_count | number|null | Number of ratings |
phone / email / website / address | string|null | Contact details (spread from the row) |
max_draft / max_length / max_width | number|null | Berth limits in meters |
vhf_channel | string|null | Marina VHF channel |
has_power / has_wifi / has_water / has_fuel / has_pumpout | boolean | Facility flags |
boat_counts / poi_counts / trip_counts | object | Raw counts keyed by type (total plus per-type) |
images | array | All location images, sorted by sort_order |
images[].url | string | Image URL. Served from https://media.charter.boats/; if you receive a URL on any other storage host, request the same path (images[].storage_path) from media.charter.boats instead |
images[].source / images[].source_url / images[].attribution | string|null | Image provenance |
images[].is_primary | boolean | Whether this is the primary image |
boats | array | The boats listed at this location, 24 per page (see page). Residents first, ordered best value first (bookable boats before boats with no bookable week), then boats visiting from another base. Card-shaped rows: id, slug, title, boat_type, capacity, length_ft, year, price_per_day, original_price_per_day, discount_percentage, model, manufacturer, location, rental_type, skippered, currency and more. required_extras carries the boat's mandatory extras per charter week — { per_day, per_booking, per_person, per_person_per_day, from } in currency — for the cheapest priced week that states a fee, the week price_per_day comes from (an all-zero fee window is unknown, not free, and is skipped while any priced week charges); from: true marks it as a floor another week exceeds. Same field, same meaning as on GET /boats |
boats[].visiting | boolean | Present and true only when the boat is based at another marina and merely spends part of the year here. Absent on resident boats. Visiting boats are listed after every resident, never merged in by rank, and only appear at all when the operator quotes them from this marina for at least 8 charter weeks a year. |
boat_count | number | The headline total — every published boat of every type based here (from locations.boat_counts.total), no price required. The same number GET /locations/{id}/card and the AI location endpoints show. It can exceed listing_total wherever a marina lists boat types the page doesn't show as individual cards (e.g. gulets) |
listing_total | number | Total of the boats listing across all pages: published sailboats, catamarans, yachts, motorboats and RIBs based here, plus visiting boats. Not the headline — it is boat_count restricted to the types this page lists, so it can be lower. No price is required — a published boat with no price yet is counted |
page / page_size / total_pages | number | Pagination of boats (based on listing_total, not boat_count; page_size is 24, total_pages is at least 1) |
min_boat_price | number|null | Lowest per-day price among the published boats based here (visiting boats not included), across all pages (display price, no markup) |
pois_by_category | object | Nearby POIs grouped by category, nearest first — at most 25 per category. The cap is per category rather than overall so that a dense category cannot crowd out a sparse one: at a marina the nearest POIs are car parks, and a flat cap used to drop a chandlery or laundry from the response entirely. Each POI carries distance_meters and walking_time_minutes. For the full set of one category use /pois/search?location_id=…&category=…. |
poi_count | number | The real total nearby, not the number returned. Read from poi_counts.total, so it can exceed the rows in pois_by_category (which is capped per category). Falls back to the returned count where poi_counts is not populated. |
poi_counts | object | Per-category totals (total plus one key per category), so a category can report more than the 25 rows it ships. {} where not populated |
amenities | array | Location amenities (id, name, icon, category); often empty |
nearby_locations | array | Flat list of neighboring locations within 30nm, sorted by rating (max 12). Places with no name are omitted — a list of "Anchorage / Anchorage / Marina" identifies nothing. The places themselves remain fully addressable by id or slug. Each row carries id, slug, name, name_en (always null — name is the one name to show), city, municipality, country, harbour_category, image_url, rating |
nearby_locations[].distance_nm | number|null | Distance in nautical miles. Neighbours boats were recorded sailing to come first, then sea-route neighbours where computed, then a 30nm straight-line proximity fallback. null where none applies. ⚠️ On the recorded ones this is an estimate (straight-line × 1.3), not a routed distance — it understates anything that rounds a headland. observed_hours beside it IS measured, so do not derive a speed from the two |
nearby_locations[].observed_hours | number|null | Median time boats were actually recorded taking to get there, door to door. Not derived from distance and speed, so do not recompute it |
nearby_locations[].observed_boats | number|null | Distinct boats behind observed_hours |
nearby_locations[].boat_count | number | Published, priced boats based at the neighbor |
nearby_locations[].boats | array | Up to 3 sample boats, picked at random (id, title, boat_type, price_per_day, primary_image) |
references | array | Content sources and attributions (title, url, source_domain) |
geo_crumbs | array | Breadcrumb trail up the geography, country → region → city: { label, href }. href is a site-relative hub path (e.g. /locations/country/greece) and is absent when that hub is not a published page — render those crumbs as plain text. Empty when the location has no country |
busyness | object|null | Boat-traffic climatology from EMODnet AIS vessel density (sailing + pleasure craft, 3×3 km around the pin, monthly slices from the last four published years). null outside EMODnet coverage (non-EU waters). |
busyness.months | array | 12 entries (month 1–12, tier = very-quiet|quiet|moderate|busy|packed relative to this location's own peak month). Relative tiers only — absolute counts are never exposed, since AIS-off vessels are invisible to the source |
busyness.months[].rank | object|null | That month's typical standing among nearby spots (climatology vs climatology): { pos (exact 1-based rank), band: top5|top10|top20|top-half|bottom-half, rank? (top5 only), of, month }. The strip renders pos — the bands are coarse enough to hide real month-to-month movement (ranks 17-19 all read "top 20"). Unlike the headline rank_this_month/rank_last_month pair this goes all the way down, so a location below its neighborhood median still reports rather than returning null. null only with under 2 neighbors or no data that month |
busyness.data_through | string | Newest month in the underlying data (YYYY-MM). EMODnet publishes annually with a ~1–1.5 year lag, so all values are climatology — typical-year traffic, never live counts |
busyness.rank_last_month | object|null | Popularity among this location + its neighbors for last calendar month, from the latest published year, banded: band = top5 (with exact rank)|top10|top20|top-half, plus of and month. null when the location sits in the quieter half of its neighborhood |
busyness.rank_this_month | object|null | Same for the current calendar month — an expectation based on the latest published year, not an observation |
ais | object|null | What boats were observed doing here, from the AIS position archive. Distinct from busyness: that is EMODnet density climatology ("how busy is this month"), this is recorded visits ("when do boats arrive, do they stay the night"). null until a place has at least 4 distinct boats and 8 recorded stays — thin evidence is withheld rather than shown weakly |
ais.kind | string | overnight (≥70% of visits stay the night), day_stop (≤30%), or mixed |
ais.nightPct | number | Share of recorded visits covering local 03:00 |
ais.medianStayH | number|null | Median hours a boat stays |
ais.fillHour | number|null | Local hour by which half the day's arrivals have landed. Arrivals only, on purpose — folding departures in would move a "when should I turn up" answer to an hour nobody turns up |
ais.fullHour | number|null | Local hour by which four fifths have landed — the practical "be there by" time. Deliberately not the peak hour: the peak is when the most boats arrive at once, i.e. the hour you least want to turn up in |
ais.busiestHour | number|null | Local hour with the most MOVEMENTS, i.e. the peak of busyHours.total. 🚨 Not an arrival time and not advice about when to turn up — at most harbours it is the morning departure rush. fillHour/fullHour above are the arrival-side answers, and they are deliberately arrivals-only for that reason |
ais.busyHours | object|null | Popular times by local hour. { total, arrivals, departures, hasDepartures } — three arrays of 24 raw counts, where total[h] = arrivals[h] + departures[h]. Busy means MOVEMENT, in both directions: at a charter base the morning departure rush and the evening arrival rush are two separate humps, and counting only one of them calls the busiest hour of the day quiet. Scale against this place's own peak, never against another place's — AIS-off boats are invisible, so the shape is comparable and the level is not. hasDepartures is false on a record written before departures were counted; departures is then all zeros and total equals arrivals |
ais.weekGrid | object|null | The same figure across the week: { rows, arrivals, departures, peak, busiestDay, hasDepartures }. Each is 7 weekdays (Monday first) × 24 hourly columns of raw counts; rows is movements (in + out), with the two directions also given separately. null under 40 recorded stays, and busiestDay is null unless one day genuinely leads — a flat week has no busiest day. ⚠️ Counting departures moves the busiest weekday on 37% of places against an arrivals-only count, so do not compare this against a figure derived from arrivals alone |
ais.frame | object|null | { minLat, minLon, maxLat, maxLon } around where boats were actually recorded stopping, for framing a map on the evidence instead of a guessed radius around the pin |
ais.hiddenGem | boolean | Busy with boats, yet Google has no reviews of it. Only ever true for anchorage-like places (bay, anchorage, mooring) that we have already enriched — a marina without a rating is a gap in our data, not an undiscovered spot |
ais.landAccess | string|null | road, track, foot or none — how (if at all) the place is reachable overland |
ais.boats / ais.stays / ais.observed | number / number / object | The evidence behind the block: distinct boats, recorded stays, and the observation window { from, to } (YYYY-MM-DD) |
ais_passages | array | Observed hops from here, best-evidenced first: to_location_id, hours (median sailed time, departure to arrival), spreadLow/spreadHigh (p25–p75), nm, observations, boats, and a location stub (id, name, slug, category, fullHour). At most 24. A pair needs at least 2 recorded passages, and a leg whose implied speed is implausible (over 15 kn) is left out. A leg whose destination has no name, or is not published, is dropped. outObs/inObs count the passages recorded leaving and arriving here |
ais_passages[].nm | number|null | ⚠️ An estimate, not a routed distance — straight-line × 1.3, because sea routing does not yet cover these pairs. It is reasonable for an open crossing and understates anything that rounds a headland, sometimes badly. hours is measured and nm is not, so never derive a speed from the pair: plan on hours |
ais_passages[].depHour etc. | number|null | A passage is undirected — the same water takes the same time either way — so hours and nm are shared, but clock hours are not: depHour/arrHour describe leaving here, inDepHour/inArrHour describe arriving here, and outObs/inObs count the evidence for each. A direction with no observations returns null rather than borrowing the other one's times |
ais_piers | object|null | Berthing density as a baked image: url (PNG on media.charter.boats), bounds ([minLat, minLon, maxLat, maxLon] — the corners to draw it between), boats (distinct vessels behind it) and cells (occupied 30 m cells, the evidence weight). Overlay it on a map using bounds; it carries transparency. Colour is turnover — how often boats arrive and leave each spot — shaded relative to this place's own busy end, so it compares corners of one harbour and never one harbour against another. Only cells holding 2+ distinct boats contribute, and the response contains no per-cell coordinates at all, so it shows a stretch of water in use and can never be resolved to an individual boat or operator |
Error Responses
404 Not Found
When the location you asked for — by either slug or numeric id — has since been merged into another, the same real-world place held twice and reconciled, the 404 carries the survivor's slug (or its numeric id, if it has no slug) so you can follow it instead of dropping the place:
data.moved_to is a slug (or id) for this same endpoint (GET /api/locations/marina-frapa).
Treat it as a permanent move and update any stored id. It is absent — a plain 404 —
when the location never existed, is not published, or was merged before this was
recorded, so branch on its presence rather than assuming it.
A merge can also hand the survivor the slug its twin was holding, when that slug is
what the survivor's own name mints and only the duplicate was keeping it away. The
survivor's previous slug then answers the same way — a 404 with moved_to — so a
client that follows moved_to needs no special case for it.