Charter Boats
Charter Boats API
Locations

Get Location

Get full details for a specific location

Endpoint

GET /locations/:id

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

ParameterTypeDescription
idnumber|stringLocation ID (e.g. 11336) or slug (e.g. d-marin-lefkas). Both resolve to the same row; the slug is canonical

Query Parameters

ParameterTypeRequiredDescription
pagenumberNoPage of the boats[] listing (default: 1, 24 boats per page). Every other field is the same on every page

Example Request

curl "https://charter.boats/api/locations/d-marin-lefkas" \
  -H "X-API-Key: YOUR_API_KEY"

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.

{
  "id": 11336,
  "slug": "d-marin-lefkas",
  "osm_id": 119160925,
  "name": "D-Marin Lefkas",
  "name_en": null,
  "lat": 38.8289054,
  "lon": 20.7113582,
  "website": "https://www.d-marin.com/en/marinas/d-marin-lefkas",
  "phone": "+30 2645 026645",
  "email": null,
  "address": "East Shore",
  "city": "Municipal Unit of Lefkada",
  "state": "Ionian Islands",
  "country": "Greece",
  "municipality": "Lefkada Municipality",
  "country_code": "gr",
  "description": "D-Marin Lefkas is a well-established marina that's been running since 2002...",
  "image_url": "https://cdn.d-marin.com/site/assets/LEYKADA_1st_frame_5481965ff4.webp",
  "image_source": "D-Marin",
  "rating": 3,
  "rating_count": 3,
  "max_draft": 4,
  "max_length": 45,
  "vhf_channel": "69",
  "harbour_category": "marina",
  "has_power": false,
  "has_wifi": false,
  "has_water": false,
  "has_fuel": false,
  "boat_counts": { "total": 434, "sailboat": 281, "catamaran": 151, "motorboat": 2 },
  "poi_counts": { "total": 198, "restaurant": 89, "grocery": 24, "parking": 9 },
  "trip_counts": {},
  "images": [
    {
      "id": "bab9b677-edca-4edb-903c-5d21603ec7f2",
      "url": "https://media.charter.boats/locations/bab9b677-edca-4edb-903c-5d21603ec7f2.webp",
      "source": "D-Marin",
      "source_url": "https://www.d-marin.com/en/marinas/lefkas/",
      "attribution": null,
      "is_primary": true,
      "sort_order": 0,
      "storage_path": "locations/bab9b677-edca-4edb-903c-5d21603ec7f2.webp"
    }
  ],
  "boats": [
    {
      "id": "8d1f81ba-c8e3-468c-9d03-fce534dd3393",
      "slug": "luna-rossa-ii",
      "title": "Luna Rossa II",
      "boat_type": "catamaran",
      "capacity": 10,
      "length_ft": 38.5,
      "year": 2019,
      "price_per_day": 235.86,
      "original_price_per_day": 314.49,
      "discount_percentage": 25,
      "model": "Lucia 40",
      "manufacturer": { "id": "8425d598-5ce9-40c2-ab30-6018cfe797d9", "name": "Fountaine Pajot", "slug": "fountaine-pajot", "logo_url": null },
      "location": { "id": 11336, "name": "D-Marin Lefkas", "city": "Municipal Unit of Lefkada", "country": "Greece" },
      "rental_type": "bareboat",
      "skippered": "optional",
      "currency": "EUR"
    }
  ],
  "boat_count": 444,
  "listing_total": 419,
  "min_boat_price": 40.38,
  "page": 1,
  "page_size": 24,
  "total_pages": 18,
  "pois_by_category": {
    "restaurant": [
      {
        "id": "3803486d-2df9-4454-bdd4-2b0538a3d2bf",
        "name": "Porto",
        "name_en": null,
        "category": "restaurant",
        "subcategory": "pub",
        "cuisine": null,
        "phone": null,
        "website": null,
        "opening_hours": null,
        "image_url": "https://blog.lefkadaslowguide.gr/media/images/120934616_985776948565262_5229278829100019075.width-1200_6uvbAgk.jpg",
        "description": "Porto is a **family-owned cafe, bar, and restaurant** right on Lefkada's marina...",
        "distance_meters": 176,
        "walking_time_minutes": 3
      }
    ],
    "grocery": [
      {
        "id": "7157f9a6-da60-4420-af98-b8535ad4a60b",
        "name": null,
        "name_en": null,
        "category": "grocery",
        "subcategory": "supermarket",
        "cuisine": null,
        "phone": null,
        "website": null,
        "opening_hours": null,
        "image_url": null,
        "description": null,
        "distance_meters": 70,
        "walking_time_minutes": 1
      }
    ]
  },
  "poi_count": 198,
  "amenities": [],
  "nearby_locations": [
    {
      "id": 7435,
      "slug": "preveza-marina",
      "name": "Preveza Marina",
      "name_en": null,
      "city": "Preveza",
      "municipality": "Preveza Municipality",
      "country": "Greece",
      "harbour_category": "marina",
      "image_url": "http://www.prevezamarina.com/media/1200x630/home-open-graph-image.jpg",
      "rating": 4.4,
      "distance_nm": 8.2,
      "boat_count": 24,
      "boats": [
        { "id": "8c144a8e-1c54-4039-9a98-398df4e5a40a", "title": "Naftaki", "boat_type": "sailboat", "price_per_day": 156.12, "primary_image": "https://media.charter.boats/boats/8c144a8e-1c54-4039-9a98-398df4e5a40a/images/1778376659014_40037a6c.jpg" }
      ],
      "observed_hours": 4.88,
      "observed_boats": 27
    }
  ],
  "geo_crumbs": [
    { "label": "Greece", "href": "/locations/country/greece" },
    { "label": "Ionian Islands", "href": "/locations/region/ionian-islands" },
    { "label": "Lefkada", "href": "/locations/city/lefkada" }
  ],
  "references": [
    {
      "title": "D-Marin",
      "url": "https://www.d-marin.com/en/marinas/lefkas/",
      "source_domain": "d-marin.com"
    }
  ]
}

Response Fields

FieldTypeDescription
idnumberLocation ID
slugstring|nullURL slug — the canonical key (/locations/{slug} on the site)
namestringDisplay 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_ennullAlways nullname is the one name to show
latnumberLatitude
lonnumberLongitude
citystring|nullCity
statestring|nullState or region
countrystring|nullCountry
country_codestring|nullISO country code (lowercase, e.g. gr)
harbour_categorystring|nullType of harbour
descriptionstring|nullLocation description (markdown)
image_urlstring|nullPrimary image URL
image_sourcestring|nullAttribution label for image_url
ratingnumber|nullAverage rating
rating_countnumber|nullNumber of ratings
phone / email / website / addressstring|nullContact details (spread from the row)
max_draft / max_length / max_widthnumber|nullBerth limits in meters
vhf_channelstring|nullMarina VHF channel
has_power / has_wifi / has_water / has_fuel / has_pumpoutbooleanFacility flags
boat_counts / poi_counts / trip_countsobjectRaw counts keyed by type (total plus per-type)
imagesarrayAll location images, sorted by sort_order
images[].urlstringImage 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[].attributionstring|nullImage provenance
images[].is_primarybooleanWhether this is the primary image
boatsarrayThe 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[].visitingbooleanPresent 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_countnumberThe 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_totalnumberTotal 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_pagesnumberPagination of boats (based on listing_total, not boat_count; page_size is 24, total_pages is at least 1)
min_boat_pricenumber|nullLowest per-day price among the published boats based here (visiting boats not included), across all pages (display price, no markup)
pois_by_categoryobjectNearby 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_countnumberThe 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_countsobjectPer-category totals (total plus one key per category), so a category can report more than the 25 rows it ships. {} where not populated
amenitiesarrayLocation amenities (id, name, icon, category); often empty
nearby_locationsarrayFlat 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 nullname is the one name to show), city, municipality, country, harbour_category, image_url, rating
nearby_locations[].distance_nmnumber|nullDistance 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_hoursnumber|nullMedian 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_boatsnumber|nullDistinct boats behind observed_hours
nearby_locations[].boat_countnumberPublished, priced boats based at the neighbor
nearby_locations[].boatsarrayUp to 3 sample boats, picked at random (id, title, boat_type, price_per_day, primary_image)
referencesarrayContent sources and attributions (title, url, source_domain)
geo_crumbsarrayBreadcrumb 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
busynessobject|nullBoat-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.monthsarray12 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[].rankobject|nullThat 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_throughstringNewest 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_monthobject|nullPopularity 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_monthobject|nullSame for the current calendar month — an expectation based on the latest published year, not an observation
aisobject|nullWhat 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.kindstringovernight (≥70% of visits stay the night), day_stop (≤30%), or mixed
ais.nightPctnumberShare of recorded visits covering local 03:00
ais.medianStayHnumber|nullMedian hours a boat stays
ais.fillHournumber|nullLocal 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.fullHournumber|nullLocal 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.busiestHournumber|nullLocal 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.busyHoursobject|nullPopular 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.weekGridobject|nullThe 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.frameobject|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.hiddenGembooleanBusy 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.landAccessstring|nullroad, track, foot or none — how (if at all) the place is reachable overland
ais.boats / ais.stays / ais.observednumber / number / objectThe evidence behind the block: distinct boats, recorded stays, and the observation window { from, to } (YYYY-MM-DD)
ais_passagesarrayObserved 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[].nmnumber|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|nullA 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_piersobject|nullBerthing 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

{
  "statusCode": 404,
  "message": "Location 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:

{
  "statusCode": 404,
  "message": "Location not found",
  "data": { "moved_to": "marina-frapa" }
}

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.

On this page