Search Boats (AI)
Search charter boats with fuzzy location matching, availability checking, and deal ranking
Showing API v1.1 — versionsShowing API v1.0 — versions
/ai/boatsSearch available charter boats by location, type, dates, capacity, and budget. Optimized for AI consumption with fuzzy location matching, availability bitmask filtering, and batch price resolution.
You are reading API v1.1 — send api_version=1.1 to get it. A call to /api/ai/* that names no version gets 1.0; switch the picker at the top to v1.0 to read that shape.
You are reading API v1.0, what a call to /api/ai/* gets when it names no api_version. A search row carries discounted_price_per_day (the charter per day, our auto-applied offer already taken off), discount_percentage, discount_label, required_extras, required_fees and the "~… EUR total for the week of …" price_summary in place of list_price, card, rental_type and has_multi_port. The detail carries pricing.ranges[].price_per_day, its deposits inside fees.required, slots without prices, and no stay_price. New integrations should send api_version=1.1 — see Versions.
Authentication
No authentication required. A key is optional — see identifying yourself. A valid API key records the call against your account, and when that account has an active affiliate slug every boat url in the response carries it as your referral (see Linking to a boat). An invalid key is ignored, never a 401. All /ai/* endpoints share one rate-limit budget of up to 60 requests per 10 minutes (30 for Custom GPT clients) without a key — a ceiling to stay under rather than an exact allowance, and a valid API key has its own, larger allowance; past it you get 429 with a Retry-After header. See Rate limits.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
location | string | No | Destination — marina, city, island, sailing region, or country, written freely (Lefkada, Greece is fine). See Location Matching. |
search | string | No | A boat's NAME (Papillon), matched against the boat's name and nothing else, as a WHOLE WORD (Bella matches Bella Luna, never Marbella; send the whole name, not a prefix) — it is never read as a make or a model (search=Oceanis finds the boats called Oceanis, not the Beneteau range). Make and model belong in makemodel; send both to find a named boat of one make. A name search needs no location, and also returns named_matches or name_not_found. |
makemodel | string | No | Make and/or model, written the way a guest says it: Bali 4.2, FP 41, Fountaine Pajot, Lagoon, Lucia 40. Make alone is fine, and so is a model alone — a model with no make in front resolves against the model catalogue. Do not split it or add punctuation — the split is resolved against the catalogue, not by spelling. Acronyms resolve through manufacturer aliases (FP, IY). See Make and model in one term. |
m | string | No | An already-resolved make/model filter, slug or slug:model (bali, bali:4.2); comma-separate to combine (bali:4.2,lagoon). Taken verbatim and never re-parsed — use it to reproduce a search_url exactly. Prefer makemodel for anything a person typed; sending both ignores makemodel. |
boat_type | string | No | One of: sailboat, catamaran, yacht, motorboat, gulet, rib, other |
manufacturer | string | No | Manufacturer name or alias (fuzzy matched, e.g. Bali, Beneteau, FP). A make resolved from makemodel replaces it; makemodel covers this for most callers. |
features | string | No | Comma-separated equipment the boat must have — all of them. Canonical slugs (air_conditioning, watermaker) or loose synonyms (ac); see Features for the vocabulary. What resolved is echoed in features_applied; anything unrecognised is ignored and listed in features_unmatched. |
guests | integer | No | Minimum guest capacity |
date_from | string | No | Charter start date (YYYY-MM-DD). Enables availability filtering and a price summary for the whole stay. Past dates are auto-corrected to the next matching future date. |
date_to | string | No | Charter end date (YYYY-MM-DD). Typical charters are 7 days (Saturday to Saturday). |
price_min | number | No | Minimum price per day in currency (EUR when omitted) for the boat, all fees in — a boat's list figure (list_price.per_day): the grand total at max guests (charter, every required fee now or at pickup, the 45 EUR platform fee, our discounts; deposits never) ÷ days. Undated, the boat's lowest; with dates, the searched week's. |
price_max | integer | No | Maximum price per day in currency (EUR when omitted) for the boat, on the same list figure as price_min. Decimals are floored, and 0 means "not asked" — the same for every number below. |
total_max | number | No | Maximum grand total for the stay in currency (EUR when omitted) — screened on the total price_summary states (list_price.total: the boat page's own Total for the stay it names, for the guests given, else max guests); a month-wide range on the weekly list_price.per_day × the stay's days at max guests — the charter, every required fee now or at pickup and the flat 45 EUR platform fee; deposits never included. Requires date_from/date_to. |
cabins | integer | No | Minimum number of guest cabins (crew cabins are not counted) |
length_min | integer | No | Minimum length in feet |
year_min | integer | No | Minimum build year |
sort | string | No | Sort order: best_value (default), deals, price_low, price_high, newest |
limit | integer | No | Results to return (default: 5, max: 25) |
currency | string | No | EUR (default) or USD, API version 1.1: every amount in the answer — list_price, price_summary, inventory.per_day — is written in it, at the rate the site's currency selector uses, and price_min, price_max and total_max are read in it. search_url and alert_query stay in euros, as the website's own links carry them. Version 1.0 answers in euros. |
Request
With no api_version the answer is v1.0; api_version=1.0 asks for it by name.
Response
A real v1.0 response, GET /ai/boats?location=Lefkada&limit=1&date_from=2027-06-05&date_to=2027-06-12 with no api_version:
When no dates are passed, price_summary states the boat's lowest week ("From 3040 EUR total a week, 12 guests. …") and inventory.by_type is only present when boat_type was not filtered. list_price is still sent: the boat's lowest list figure, marked from.
There is deliberately no available flag. It read as "available for the dates you asked for" while price_summary quoted a different week, and callers relayed the flag. The week a boat can actually be sold is stated in price_summary and nowhere else, so there is one answer to repeat.
A dated row is the boat page's own quote for the stay asked — the dates asked where the boat takes them, else from its changeover day for the nights asked, or its minimum stay where that is longer — for the guests given (else max guests): list_price.total is the page's Total and per_day that total ÷ the stay's days. price_summary names that stay and its total ("the week of Sep 26–Oct 3" for 7 nights; any other stay its dates and length, "Sep 26–29 (3 nights)", "Sep 26–Oct 4 (8 days)" on a day-clock boat); a month-length range states the range of its weeks' totals ("X–Y EUR total a week across Oct 2026, 12 guests."). For the price of the actual stay — Due now, Paid at pickup, the Total and the deposits — call get_boat_details with the dates.
Charter weeks are derived from our stored per-week rates, which the partners quote per 7 nights. MMK and Nausys price the trip itself and a longer booking often carries a discount those rates cannot express, so a multi-week total here runs slightly high; the exact figure is re-quoted from the partner at reservation and the difference reaches the guest as a reduction.
Everything in boats carries a price, with one exception: a search with no dates and no price filter (min, max or trip total) also lists the published boats we hold no price for, as a tail after the priced ones (see unpriced_start_index) — each with bookable_as_searched: false and every price field null. Any dated search, or one with a price filter, excludes them, since there is nothing to quote: undated searches then require a current daily rate, dated searches an available week that is priced. alternatives never carries an unpriced boat.
The other exception is a search by boat name, where "we have nothing" is the wrong answer about a hull that plainly exists — those boats come back in a separate named_matches array, never in boats.
Response Fields
| Field | Type | Description |
|---|---|---|
boats | array | Matching boats ranked by the selected sort order |
boats[].id | string | Boat UUID |
boats[].url | string | Share link to the boat page (https://chrtr.me/…). On a dated search it carries the week price_summary quotes; with your key it carries your affiliate referral. Treat it as opaque — see Linking to a boat |
boats[].title | string | Boat name |
boats[].boat_type | string | Type of boat |
boats[].manufacturer | string|null | Manufacturer name |
boats[].model | string|null | Model name |
boats[].location | string | Formatted location string (marina, city, country) |
boats[].year | integer|null | Build year |
boats[].length_ft | number|null | Length in feet |
boats[].capacity | integer|null | Maximum guests |
boats[].cabins | integer|null | Number of guest cabins (crew cabins are not counted) |
boats[].toilets | integer|null | Number of heads/toilets |
boats[].skippered | boolean|string | true (a skipper comes with the boat, inside the charter price), "required" (a skipper comes with the boat but is billed as a mandatory fee on top — get_boat_details lists it under required fees; never say the price includes him), "optional" (one can be added, at a price), or false (bareboat). Derived from the boat's live fee set, not from rental_type alone |
boats[].discount_percentage | number | Active discount percentage. 0 if none. |
boats[].discount_label | string|null | Discount description (e.g. "50% discount"). null when no discount. |
boats[].price_rank_percentile | number|null | Where this boat's daily rate sits within the matched inventory (0 = cheapest, 100 = most expensive) |
boats[].currency | string | Currency code (e.g. EUR) |
boats[].price_summary | string|null | The one sentence to quote: "T EUR total for the week of Sep 26–Oct 3, N guests[. P% discount applied.]" — the boat page's Total for the stay, for the guests given (else max guests), all fees in, deposits never; any other stay "T EUR total for Sep 26–29 (3 nights), N guests. …"; the percent is list_price.discount_pct, not stated when there is none. Dateless: "From T EUR total a week, N guests. …" (the lowest week, off the precomputed weekly figures). Changed 2026-10-02 (was "P EUR per day for the week of …"; null without dates). |
boats[].list_price | object|null | The row's ONE list figure: per_day — the grand total (the charter less the boat's auto discount, every required fee charged now or at pickup, the 45 EUR platform fee; deposits never) ÷ its days, per day for the boat, a whole euro rounded up: dated, the boat page's Total for the stay and party asked; dateless, the lowest week of the default package at max guests; total — that grand total (dated only, null without a week; per_day × units may exceed it by up to units − 1 EUR, since only per_day is rounded up); original_per_day — the same with the partner's discount undone and no coupon, per day (null without a discount); discount_pct — derived from per_day and original_per_day, null under 1%; from is true when no week was quoted and it is the boat's lowest; week, week_start, units, unit (night/day) say what total covers (week_start the stay's check-in, units its length); party the guests the fees are priced for — the guests given, else max guests; label the words beside the stay total (dated "for 3 nights · all fees in", dateless "7 nights · all fees in", "8 days" on a day-clock boat). For the complete price — what is due now, paid at pickup, the total and the deposits — call get_boat_details with the dates (stay_price). |
boats[].checkinout_days | array | Allowed check-in/turnover weekday(s), e.g. ["Saturday"] |
boats[].min_charter_days | integer|null | The boat's minimum charter length in nights for the season in force at the searched check-in (today without dates), when known — on a day-clock boat that is one more charter day, and named_matches[].reasons states it that way ("minimum charter is 8 days") |
boats[].instant_book | boolean | Whether a guest can book this boat INSTANTLY — the operator's instant-book mode AND something currently bookable. false on an instant-book boat with no open dates, because the guest would have to inquire; it is not a statement about the operator's settings alone. |
boats[].hero_image_url | string|null | Primary boat image, as the 400×300 WebP card variant — hotlink it directly; the un-resized original is not a public URL. |
total_matching | integer | How many boats match the whole search before limit, so you can say "top 5 of 122" and know whether a bigger limit is worth it. Omitted when it cannot be known exactly — a total_max budget or a stay shorter than 7 nights filters rows after counting. |
results_ordered_by | string | Present when boats holds more than one boat — the list is already in presentation order; do not re-sort it |
dates_moved | object | Present when date_from/date_to was in the past and the search ran on the same day next year instead — { requested, searched, note }. The pair moves together and keeps its length. Tell the guest their dates have passed; never that they have no availability yet |
dates_note | string | Present when date_from is not a changeover day (stays up to 14 nights) — each price_summary then names the charter week actually on offer, and those are the dates to give the guest |
features_applied | array | Present when features was sent — the equipment slugs actually filtered on |
features_unmatched | array | Present when some features terms were not recognised — they were ignored |
inventory | object|null | Aggregate stats for the searched area. null when location did not resolve. |
inventory.total_boats | integer | The number of boats matching the search — the same set boats is drawn from, and always equal to total_matching. Every filter you sent is applied, dates included. Priced boats only, except on a search with no dates and no price filter (min, max or trip total), where it also counts the unpriced tail (unpriced_start_index). |
inventory.per_day | object|null | { min, median, max } of the boats' dateless list figures (per day for the boat, the grand total at max guests, whatever the dates). median is the true median, rounded. |
inventory.capacity | object|null | { min, max } guest capacity |
inventory.length_ft | object|null | { min, max } length |
inventory.by_type | object | Boat counts per type (only when boat_type not filtered) |
location_matched | object|null | The location the query resolved to (id, name, country). A sailing-region match reports id as "region:<Name>" (e.g. "region:Dalmatia") and the region's own name. |
search_url | string | The same search on charter.boats — every filter you sent, so the page shows the answer you were given. Absent when the search used something the website cannot express (see search_url_unavailable); when location did not resolve it is a plain text search for the term you sent. |
alert_query | string | The same search as /api/boats query params — what an availability alert stores and replays, derived from search_url so the two cannot describe different searches. Absent when location did not resolve, or when there is no link. |
search_url_unavailable | string | Present INSTEAD of search_url when no website link can reproduce this answer: a make/model term the catalogue could not resolve (matched here as model text), a make matched loosely (Benet → Beneteau, which has no picker value), a boat_type the website's filter does not offer (gulet, other), a total_max whole-trip budget (the page has only a per-day cap, so its link would show boats over the budget), or an explicit sort (the page carries no ordering in its URL, so the first screen would be different boats). Do not build a link yourself: it would show more boats than you were given. Link the individual boats instead. |
alert_horizon | object | { from, to } — the date window availability is known for (the current anchor week plus 52 weeks). Alerts cannot watch dates past to. Absent when location did not resolve. |
alternatives | array | Present when boats is shorter than limit and something could be loosened — the closest boats, in the same shape as boats plus relaxed. See Alternatives. |
alternative_inventory | object | Present when a place was searched and boats is empty — the same spec across the widened area: total_boats (matching at any date), available_nearby_or_within_7_days (how many of those are free in the searched week or the week either side), by_type, and ranges for per_day, capacity, length_ft. total_boats 0 means this coast does not have that boat; a high total_boats with 0 available means the week is the problem, not the place. |
search_interpreted | object | Present only when a term was read as a make, optionally plus a model, or when a boat's name was folded off the end of makemodel — { make, model, note }. model is "" when the term was a make alone; make is "" when a folded term's head matched no make or model and ran as model text. See below. |
named_matches | array | Present only on a name search — published boats carrying that name this search could not offer. Same boat shape as boats, plus bookable_as_searched, not_offered_because, availability_note. See Searching by boat name. |
named_matches_note | string | Present alongside named_matches — how to present them |
name_not_found | string | Present when a name search matched nothing anywhere in the fleet. Mutually exclusive with named_matches. |
listed_unavailable | array | Present only when a plain search by a single exact location (+ optional dates, no other filters) returns nothing at all — no boats, no alternatives, no named_matches. Published boats at that exact location with no sellable offer right now. Same boat shape, bookable_as_searched: false, not_offered_because, availability_note — but pricing fields are forced null. See Listed but unavailable. |
listed_unavailable_note | string | Present alongside listed_unavailable — how to present them |
no_results | object | Present only when the response has nothing to hand over — no boats, alternatives, named_matches or listed_unavailable. { searched, why, try_next[] }: what was actually searched after resolution, which constraint emptied it, and the concrete calls to try next. |
location_unresolved | string | Present when the location you sent matched no place we hold. Echoes the term that failed; no search is run rather than a worldwide one. |
note | string | One-sentence rendering of no_results, generated from it so the two can never disagree. |
| Field | Type | Description |
|---|---|---|
boats[].crew_cabins | integer|null | Number of crew cabins, as the operator gives it (null when not given) |
In v1.0 a row has no list_price, card, rental_type or has_multi_port, and inventory.per_day is not there. In their place:
| Field | Type | Description |
|---|---|---|
boats[].discounted_price_per_day | number|null | The charter per day for the quoted week, our auto-applied offer already taken off — no fees inside |
boats[].discount_percentage | number|null | The charter discount, percent |
boats[].discount_label | string|null | Always present: the discount's name, e.g. "Charter discount" |
boats[].required_extras | object | The required fees beside the day rate: per_day, per_booking, per_person, per_person_per_day (EUR), from |
boats[].required_fees | object | Those fees for the quoted stay: total, unit, nights, charter_unit, from, at_capacity |
boats[].price_summary | string|null | "~T EUR total for the week of Sat 5 Jun 2027 → Sat 12 Jun 2027, N guests. P% discount applied. Charter: … Required per-booking fees: … Platform fee: 45 EUR" — the same total as v1.1, with its parts spelled out |
inventory.discounted_price_per_day | object|null | { min, median, max } — the figures v1.1 calls inventory.per_day |
Linking to a boat
boats[].url is a share link to the boat's page on chrtr.me. Treat it as opaque — don't parse it, shorten it or rebuild it. It can take three shapes, and which one you get depends on the call:
- a bare 6-character boat code (
https://chrtr.me/R58ZDk) — nothing carried; - the code plus a charter week, and your referral when you called with a key (
https://chrtr.me/n8wxFd0DG7) — on a dated search, when the boat can be sold for the week(s) you asked about; - the code plus your referral only (
https://chrtr.me/rkt5rx-yourslug) — a keyed call whose link carries no week.
The week a link carries is exactly the week price_summary quotes. A month-long range, or a boat that is only sellable on a neighbouring week, gets a link with no dates. A carried week is dropped automatically once its check-in date has passed. Passing the link on unchanged is what keeps a booking attributed to you: the referral is captured on arrival and held in a first-party cookie for your attribution window, so it survives the guest browsing on before they book.
The boat page reads three params, and each one you append overrides what the link carries:
| Param | Meaning |
|---|---|
d | Dates, YYYY-MM-DD,YYYY-MM-DD. The second is a check-out date, so 2026-10-17,2026-10-24 is seven nights. Always send a range — a lone date skips the minimum-nights check and quotes a one-day stay the booking path then refuses. |
g | The number of guests. The boat page prices per-person fees for that party (capped at the boat's capacity); without it the page prices at capacity. |
ref | An affiliate slug. You rarely need it: a keyed call already puts yours in the link. |
Any other param you append is passed through to the boat page unchanged. A bare code with no params redirects 301; a link carrying dates, a referral or any appended param redirects 302.
The carried week is the quoted week, not a verified check-in. It comes from the same weekly availability data as price_summary, which does not attest the check-in day itself, so roughly one link in twenty-five lands on a start date the boat page rejects and the guest sees an empty calendar. When you need a date the guest can book, read /ai/boats/{id} — it accepts the link you already have — and take a slot's checkin and checkout from bookable_slots (both YYYY-MM-DDTHH:MM, so slice the date off each), then append them as ?d=. That list is the only thing that has checked the check-in day against the boat's real turnover days, its quoted charter length, and live availability. Never reconstruct d from checkinout_days. Or call /ai/boats/{id} with the guest's date_from and date_to: its url carries that stay when we sell it, and no dates otherwise — your referral either way.
When nothing matches
An empty boats array on its own is not an answer — it leaves you unable to tell "there are no
boats in Lefkada" from "none of them sleep 99". So a response that has nothing to offer always
carries no_results, and the two cases read differently:
The place did not resolve. No search is run at all, rather than a worldwide one wearing the
label of a place. location_unresolved echoes the term that failed, inventory and
location_matched are null, alert_query/alert_horizon are absent, and search_url is a
plain text search for the term.
The place resolved and the constraints emptied it. searched names the resolved place and
the filters applied — dates, guests, cabins, type, a resolved model, price, length, year and
equipment — and try_next lists them in the order a guest would relax them, the widest first,
since that is the one most likely to be doing the emptying on its own:
It deliberately does not guess which single filter did it: the query is one conjunction, so the endpoint cannot tell, and a confident wrong cause is worse than the listed set.
no_results is absent whenever there is something to offer — an alternative, a name match or a
listed-unavailable hull is an answer, and each carries its own note explaining how to present it.
It can appear beside name_not_found; when it does, name_not_found is the answer to give.
Alternatives
boats is always the true answer to the question asked — never padded, never
widened. When it comes up short of limit, alternatives carries the closest
boats we do have, and each one says what it gave up in relaxed:
relaxed | What was loosened |
|---|---|
dates | The week either side of the ones asked for |
place | A nearby base (the nearest marinas holding ~30 boats of the type, up to 60 nm) |
model | Another model of the same make — the make is kept |
name | The boat carries ONE word of the searched name (Lyra Astra → Lyra, Astra Blue) |
One thing is loosened at a time, in that order, and the ladder stops as soon as the page is made up — so a boat that gave up one thing always comes before one that gave up two. Nothing else is ever loosened: type, guests, cabins, berths, length, year, budget, equipment and the operator behind a referral all still hold for every row.
Say which: an alternative is not what the guest asked for, and it counts toward
no total — total_matching describes boats alone. The website's own search
pads a short page from the same ladder, so the link in search_url shows the
guest the same boats.
Searching by Boat Name
A boat is only in boats if it can be sold as searched — priced, free for the
dates, at the base asked for. For a search by NAME that is the wrong answer:
"Sunny" either is or is not our boat, and an empty list reads as "no such boat"
to the guest asking and to the owner checking their own listing.
So a name search additionally returns every published boat carrying that
name that the filters could not offer, in its own named_matches array —
never merged into boats, which stays exactly what it was: bookable,
priced, quotable.
Read the two boats above as they are meant: the first is free that very week
and only fails on the base (Punat, not Split); the second fails on both. That
distinction is the whole point of not_offered_because — it is derived per boat
from the row, not a single blanket sentence.
| Field | Meaning |
|---|---|
bookable_as_searched | Always false here. Named as searched on purpose: a boat whose only problem is the base is perfectly bookable once the guest accepts that base. |
not_offered_because | One or more concrete reasons: a Lagoon 46, not the Lagoon 55 asked for (a make/model was searched beside the name), based at X, outside Y, not free for the dates asked, not on sale at the moment — no priced season loaded…, or a spec/budget miss (takes 6 guests, fewer than the 12 asked for). |
unpriced_start_index | Present only on a search with no dates and no price filter (min, max or trip total): the index in boats from which every entry is a published boat we hold no prices for. They are matches — inside total_matching and inventory.total_boats — but each carries bookable_as_searched: false, not_offered_because: ["not on sale at the moment — no priced season loaded, so there is no price to quote"] and null for every price field. unpriced_note says the same in a sentence. Never quote or estimate a price for one; name it, link it and offer an availability alert. Added 2026-09-21. |
availability_note | What may and may not be quoted for this boat, matched to what the object actually carries — on an undated search price_summary is the boat's lowest week ("From …"), and the note says it is not a quote. |
Never counted. total_matching and inventory.total_boats describe the
searched area and exclude named_matches entirely — five same-named boats
elsewhere must not become "five boats in Split".
What does NOT produce named matches
The array is for a hull's NAME, so it is skipped when the term is something else:
- Terms under 3 characters, or with no letter in them.
- Boats that merely fell past
limit— a boat with no reason to be excluded is one the search could have offered, so it is dropped fromnamed_matchesrather than mislabelled. Raiselimitto see it inboats.
Matching is on whole words, so Bella returns Bella, Bella Luna and Isola
Bella — never Marbella.
A make or model beside the name (makemodel, m or manufacturer) does not
skip it: the name still filters boats, the fleet-wide lookup runs as well, and
a named hull of another make or model says so in not_offered_because (a Lagoon 46, not the Lagoon 55 asked for) instead of being absent.
When the name exists nowhere
If a name search matches nothing across the entire fleet, the response says so
explicitly rather than leaving an empty boats to be read as "nothing here":
A one-word name has nothing to loosen to, so this answer carries no
alternatives. A name of two or more words does: Lyra Astra comes back with
the boats named Lyra and Astra Blue, each marked relaxed: ["name"] — and
name_not_found still says, in the same response, that no boat carries the
whole name.
named_matches and name_not_found never appear together: the first means the
name exists and could not be sold as searched, the second that it does not exist.
Listed but Unavailable
A plain search for one exact marina (no name, make, budget or other filter —
just a place, and optionally dates) can still come back with nothing to sell:
no strict match, no nearby/date alternative, no name match. That location page
still visibly lists a published boat, so an empty boats array would read as
"nothing is there" about a hull the guest can see exists.
listed_unavailable carries a few of those published boats — outside boats
and total_matching, with bookable_as_searched: false and every pricing
field forced null. Never quote a price from one; the only correct actions are
naming it, giving its reason, and offering an availability alert.
This is scoped tight on purpose: it only fires for a search that named ONE unambiguous place. A city with several marinas (Split, Palma) or a country/state/region match never triggers it — pointing "the one boat listed here" at an arbitrary member of a multi-marina city would misname where it actually is.
Make and Model in One Term
Bali 4.2 finds nothing on a literal reading: the make lives in a separate
manufacturers table and the fleet stores the model on its own (4.2), so no
single field ever holds that string. Send it as makemodel and the endpoint
splits it — longest make prefix first, so Fountaine Pajot Helia 44 resolves as
one make and one model.
Send it as one string. Deciding where a make ends is a question about the
catalogue, not about language: Arno Leopard 23 is a two-word make, Custom Made is a make with no model, and FP is an alias for Fountaine Pajot. The
endpoint owns that mapping, so pass the phrase through unedited.
The split is only applied when that make actually has a boat matching the
remainder. This matters because some model lines share a name with a
manufacturer: there is a manufacturer row called "First" that owns no boats, so
First 40 is never redirected to it.
A term with no make in front is read as a model. When no split applies,
makemodel is looked up in the model catalogue on its own — Lucia 40, or
First 40, which resolves to the boats listed as exactly that model (a First
40, not a First 40.7). Model names are not shared between builders, so no
make is needed to pin one down. This step belongs to makemodel only: search
is a boat's name and is never looked up as a model.
A term that resolves to nothing may carry the boat's NAME on its tail. A guest
folds the hull's name into the phrase — "How much is the RAND Play 24 Diva?" — and
the whole string matches no make and no model. When no search was sent beside
it, the trailing words are tried as a boat's name before the words fall back to
model text: the longest
run first (Okea Iris before Iris), as whole words, against published boats, with
at least one leading word kept for the make or model. When a run names a boat the
request continues exactly as makemodel=RAND Play 24&search=Diva would — the head
is read again through the same split and catalogue lookup, the name reaches the
strict filter and named_matches, and search_interpreted says what was read:
A term that resolves to nothing still filters — when no trailing run names a
boat either, it is matched as model TEXT, so it finds nothing honestly rather than
falling back to no filter at all (Nonsense Brand 9 used to come back as five
arbitrary boats, which reads as "here is what you asked for"), and the no_results
line says that no catalogue make or model matches it and its trailing words name
no published boat. The website has no param for that match, so such an answer
carries search_url_unavailable instead of a link.
When the make and the model half both resolve to a catalogue entry, the search
filters on every stored spelling of that model (the fleet writes both 4.1 and
Bali 4.1), and the returned search_url carries it as m=bali:4.2. That URL
reproduces the same result set on the website — the API answer and the link agree
by construction, which the test suite checks by following every link back to the
API. A bare model resolves the same way: Oceanis 46 and Lipari 41 are
catalogue models and travel as m=beneteau:Oceanis 46, never as free text —
q is a boat's NAME on both surfaces. Only a term the catalogue cannot place at
all has no link at all.
When a make is read out of the term, search_interpreted reports it. Use it to
phrase an empty result accurately — when the model was the only constraint, no
match means that specific model is unavailable here, not that the make is missing:
When a name, dates or any other constraint ran beside it, the query is one AND and
the endpoint cannot tell which emptied it, so the note names the set instead and
never pins the zero on the model (makemodel=Lagoon 55&search=Iris: five Lagoon
55s exist, none named Iris — the two Lagoon 46 "Iris" come back in named_matches
with "a Lagoon 46, not the Lagoon 55 asked for"):
It is also present when the term was a make and nothing else (Bali), with
model: "". Only makemodel is read this way — search is always a boat's
name, so a make or model belongs in makemodel, and a boat's name in search.
Model words that match no catalogue entry are matched against the model text of
that make's boats, never against boat names.
A make resolved from makemodel replaces the manufacturer you sent. Pass m
when you already hold a resolved filter — it is never parsed.
Location Matching
location is written freely — by a guest or by a model — and resolved in this order:
- Sailing region.
Istria,Kvarner,Dalmatia,the Ionian,the Saronic,the Cyclades,the Dodecaneseandthe Sporades(with common spellings such asDalmacijaorKyklades, a leading "the" and a trailing country) resolve geographically, to every boat-carrying location inside the region's area and in that region's own country — the Dodecanese never returns boats from the Turkish coast beside it.location_matchedthen reportsid: "region:<Name>". - Place names. Otherwise the term is matched against location names, cities, states and countries, trying progressively looser forms and stopping at the first that finds something:
- the whole term, then the part before the first comma — anything after a comma is a hint that narrows the match (
Lefkada, Greece,Nydri, Lefkada, Greece); - either half of a parenthetical name (
Yachtclub Seget (Marina Baotić)); - the term with a trailing geographic word removed (
Amalfi Coast→Amalfi; also region, islands, riviera, gulf, bay and similar); - the term with up to two trailing words dropped — accepted only when the rows it finds confirm the dropped words, so
Marina Agios Kosmanever degrades to every marina in the world.
- the whole term, then the part before the first comma — anything after a comma is a hint that narrows the match (
- Ranking. Among the matches: exact name > city (administrative prefixes such as "Municipal Unit of" or "Općina" are ignored) > name starting with the term > city starting with the term > state > country.
What gets searched depends on where the best match landed:
- A state, a country, or only a loose substring match — every matched location in it.
- A marina or city — every location in that city, and, where the city has five locations or fewer, every location within roughly 5 km, because boats moor at the nearest port rather than at the named spot.
With dates, a location also matches the boats that depart from it in those weeks though based at another marina.
A term that matches nothing is never widened into a worldwide search — see When nothing matches.
Sort Orders
| Value | Description |
|---|---|
best_value | Best value first, available boats before unranked ones (default) |
deals | Best discounts first, then lowest price |
price_low | Cheapest per-day rate first |
price_high | Most expensive first |
newest | Newest build year first |
Boat Details
/ai/boats/{id}Full detail for a single boat: description, specs, equipment, 365-day pricing windows, authoritative bookable check-in/check-out slots, fees, discounts, and cancellation policy. The {id} accepts a boat UUID, the boat's slug (lagoon-40-sailing-point), or the part of a chrtr.me link after the slash — a bare code (n8wxFd) or the longer dated/referral form /ai/boats hands out (n8wxFd0DG7, rkt5rx-yourslug), which names the same boat. Pass that path segment, not the whole URL: a full URL in the path is not matched. Authentication and rate limits are as for the search; a keyed call gets a url carrying your referral.
Optional date_from and date_to (both YYYY-MM-DD, check-out after check-in) name the stay you are quoting. With them, fees are the ones that apply to that stay — a fee's season is picked by the check-in date, as on the boat page — and url opens the boat on those dates when the stay is one we sell — stay_price prices it (total is not null) and, on a boat with no check-in periods, it starts and ends on the boat's changeover day; otherwise it carries no dates. Without them, fees are those in force today and url carries no dates. A check-in already in the past is moved to the next year it is not, the pair together and the stay length kept — the same rule as the search — and the response then carries dates_moved ({ requested, searched, note }). A pair that is not two real calendar dates, or is reversed, is a 400.
Optional currency (EUR, the default, or USD; API version 1.1) writes every amount — stay_price, bookable_slots[], pricing.ranges[], fees, boat.security_deposit and fixed euro offers — in that currency, as the boat page writes it in the currency selector's choice. Version 1.0 answers in euros.
Optional guests (a number, API version 1.1) is the party the stay is priced for: stay_price, every bookable_slots[] price and, with dates, the fees are then priced for that many guests, and per_person_day is per one of them — as the checkout prices a party. Without it, the boat's capacity.
Request
With no api_version the answer is v1.0; api_version=1.0 asks for it by name.
Response
The parts that differ in a real v1.0 response (GET /ai/boats/55467c1d-bfab-484f-b0c0-bb9352d4d46d?date_from=2027-06-05&date_to=2027-06-12, no api_version) — no stay_price, no fees.deposits:
Response Fields
| Field | Type | Description |
|---|---|---|
url | string|null | Share link to the boat page — carries the stay's dates when called with a stay we sell (see date_from), no dates otherwise; your referral when called with a key |
boat.id | string | Boat UUID |
boat.title | string | Boat name |
boat.description | string|null | Description (trimmed to ~500 chars). null when the boat has none or when we have withheld it — a moderator marks a listing's description inaccurate or carrying personal details, and it is then published nowhere. The field is always present. |
boat.operator_notes | string|null | The operator's own practical notes, where the partner supplies them (fuel policy, check-in conditions and the like) — unedited text |
boat.boat_type | string|null | Type of boat |
boat.manufacturer | string|null | Manufacturer name |
boat.year | integer|null | Build year |
boat.length_ft | number|null | Length in feet |
boat.capacity | integer|null | Maximum guests |
boat.cabins | integer|null | Number of guest cabins (crew cabins are not counted) |
boat.berths | integer|null | Number of berths |
boat.toilets | integer|null | Number of heads/toilets |
boat.instant_book | boolean | Whether a guest can book this boat INSTANTLY — the operator's instant-book mode AND something currently bookable. false on an instant-book boat with no open dates, because the guest would have to inquire; it is not a statement about the operator's settings alone. |
boat.rental_type | string|null | The operator's product class: e.g. skippered, bareboat. It does not say who pays for the skipper — read boat.skipper for that |
boat.skipper | string | Who sails her and who pays for him, from this boat's own fee set: included (comes with the boat, inside the charter price), required (comes with the boat, billed as a mandatory fee listed under fees.required — never say the price covers him), optional (one can be added, at a price), none (you sail it). The same statement search_boats returns as skippered |
boat.charter_schedule | string|null | Human-readable check-in day, turnover hours and minimum stay — in the unit the boat sells in, "minimum 7 nights" or "minimum 8 days" on a day-clock boat — for the season in force (the one covering today, else the next to open) — e.g. "Check-in Sat from 17:00 to 09:00, minimum 7 nights". 🕐 There is no default hour. Where the operator has not stated one, the line says so instead of inventing it: "Check-in Sat, minimum 7 nights, check-in/check-out times not set by the operator — confirm with the operator before booking", or "…, the check-out time not set by the operator — …" when only one half is missing. null means the boat states no schedule at all. Never present the hours as known when that phrase is there. |
boat.security_deposit | number|null | Security deposit amount |
boat.location | object | { name, city, country } (each may be null) |
boat.equipment | array | The boat's equipment grouped by section: { group, items[] }, items as display names (with a value in brackets where one is recorded, e.g. "Dinghy (Honda 6 HP)"). Every item with a fleet share is prefixed [n%] with the share of boats that have it, e.g. "[0.8%] Jacuzzi"; the lowest are the rarest. An item with no share (operator free text) has no prefix, and the prefix is not part of the name. Kit standard on the boat's model is listed even when the operator did not list it, with its share like any item. The same list, order and duplicate-spelling collapse as the boat page. At most 80 items across all groups; within a section the rarest come first, so the cap sheds the commonest kit |
boat.equipment_note | string | Present only when an item carries a share prefix: what [n%] means |
pricing.horizon_days | integer | Look-ahead window (365) |
pricing.currency | string | Currency code |
pricing.ranges | array | Contiguous available date windows with from, to, per_day — the LIST figure: the lowest grand total at max guests per day for the boat among the boat's weeks the range covers, deposits never in it; null when none of them is priced — and original_per_day, the same week's figure before its discount (null without one; added 2026-10-02). Quote a stay from bookable_slots or stay_price, never from it. At most 30, soonest first |
pricing.truncated | boolean | true when more than 30 ranges existed — the later ones were dropped |
pricing.note | string | Guidance on how to read pricing |
bookable_slots.note | string | Guidance on quoting slots |
bookable_slots.slots | array | Authoritative { type, checkin, checkout, nights, charter_length, due_now, paid_at_pickup, total, deposits, base_price, regular_charter, charter_discounts, auto_discount, auto_discount_label, per_person_day, product } slots (in that order since 2026-10-02: the three prices and the deposits first), soonest first, at most 60. nights is the stay's nights on every boat; charter_length is the same stay in the unit the boat sells in — "7 nights", or "8 days" on a day-clock boat — and is the phrase to quote. base_price is the charter total only, in whole euros as the boat page sells it. due_now (added 2026-10-01) is the boat page's Due now for the slot — the charter, the required fees charged at booking (a deposit charged then included) and the platform fee, less the best auto-applied offer (auto_discount, already taken off due_now, total and per_person_day) — or null where its fees cannot be priced; paid_at_pickup and total (added 2026-10-01) are what is paid at the base — the fees paid there, deposits not included — and the grand total (see the next row); deposits (added 2026-10-01) lists the slot's deposits { label, amount, charged_now }: one charged at booking is inside due_now; none is ever inside total. product names the partner package the slot is priced for (pass it on when reserving), or null. |
bookable_slots.slots[].paid_at_pickup, .total, .per_person_day | number|null | Added 2026-10-01: what is paid at the base for the slot — the fees paid there, deposits not included — and the grand total, due_now + paid_at_pickup − the deposits charged now (deposits are never in a total); per_person_day is that total ÷ the charter's days ÷ the boat's capacity. Null where a row cannot be priced. |
stay_price | object | Only with date_from/date_to (added 2026-10-01): the stay's prices as the boat page states them — due_now (charged on booking, a deposit charged now inside it), paid_at_pickup (the fees paid at the base, no deposit), total (the grand total: due_now + paid_at_pickup − the deposits charged now; deposits never in it), per_person_day (total ÷ days ÷ guests priced) — and its deposits (label, amount, charged_now); agent_commission_estimate only when your key's user is an active agent; null figures where the page does not sell those dates. |
bookable_slots.truncated | boolean | true when the 60-slot cap cut the list |
fees.required | array | Mandatory { description, amount, period, paid_at? } fees. amount is a string with its unit — "270 EUR", "9%", "50% of daily price", "3.5% of charter + advance extras" or "included"; a percentage is of the charter total for the stay, once. With date_from/date_to a percentage also states what it comes to for that stay, as the boat page does — "30% (3120 EUR)", "est." before the amount where its base is estimated (added 2026-10-01); a rate with no figure behind it stays the rate alone. paid_at is "booking" (in the online total) or "base" (settled at the marina) and is absent on an included fee. Deposits are not in this list — see fees.deposits. Scoped like the boat page: one package, the standard week, the boat's capacity as crew size |
fees.optional | array | Optional add-on fees, same shape |
fees.deposits | array | Added 2026-10-02 (moved out of fees.required, which carried them marked deposit: true): the required deposits — the APA, a refundable security or other deposit — same shape. Never in any Total: with paid_at: "booking" it is charged inside Due now, with "base" it is paid at pickup. |
discounts.note | string | Every figure already has operator_seasonal and the best public offer in it; a private code or agent grant is in none |
discounts.operator_seasonal | array | Seasonal discounts already baked into pricing.ranges |
discounts.operator_conditional_offers | array | Coupon-style offers (NOT pre-applied) with label, value, visibility (public = applied automatically at checkout, private = the guest enters the code), condition, remaining_uses, and rule — at most one coupon applies per booking |
discounts.agent_grants | array | Agent-granted discounts (NOT pre-applied) |
company.has_members | boolean | Whether the operator has staff members responding |
company.cancellation_policy | string | Plain-language cancellation policy |
| Field | Type | Description |
|---|---|---|
boat.crew_cabins | integer|null | Number of crew cabins, as the operator gives it (null when not given) |
In v1.0 there is no stay_price, no fees.deposits and guests is not read. These differ:
| Field | Type | Description |
|---|---|---|
pricing.ranges[].price_per_day | number | The charter day rate for that window, operator_seasonal already taken off, no fees inside (v1.1: per_day, all fees in, with original_per_day) |
bookable_slots.slots[] | array | { type, checkin, checkout, nights, charter_length, base_price, product } — no due_now, paid_at_pickup, total, deposits or per_person_day |
fees.required | array | Also lists the required deposits, after the fees (v1.1 moves them to fees.deposits) |
discounts.operator_seasonal[].original_price_per_day | number | The day rate before that discount |
Errors
| Status | Body |
|---|---|
400 | Boat id required |
404 | { "error": "boat_unavailable", "message": "This boat is no longer available. …" } in data — the id did not resolve to a boat, or the boat is no longer published |
Compare Boats
/ai/boats/compareOne boat set against up to 10 closest published boats of the same type in the same country, as a table of specs and the weekly price for each of the next 52 weeks — the compare_boat tool on the MCP server and in Mermaid, and the compare pages (https://charter.boats/boats/compare, and /boats/{slug}/compare for one boat). For what is better or worse about one boat, see What Stands Out. Authentication and rate limits are as for Boat Details.
Pass id (a boat id, slug or short-link code, resolved as for Boat Details), or boat_type (sailboat, catamaran, motorboat, gulet, yacht, rib, other) and country (ISO code), optionally with region and city, to describe a boat instead. The specs take Search Boats' names: makemodel (as the guest said it), length_min / length_max (feet), year_min / year_max, guests, cabins, berths, toilets. Without id they describe the boat to compare against; with id they filter its peers as the search would (a minimum for guests, guest cabins, berths and toilets; a range for length and year; makemodel keeps the boats whose make or model the words name). With id, lock (comma-separated: region, city, make, model, year, length, guests, berths, cabins, toilets) keeps only the peers the same as the boat in those fields — the fields a peer's same lists.
Closeness: the same city, region, make and model, a build year and a length inside what was asked (with id: within 2 years and 5% of the boat), and the same guests, cabins, berths and toilets, counted only for what the anchor states. A boat with no week it can be sold in over the 52 is left out. With id, and when that boat sells at least one week, each peer's overlap_pct is the share of its open weeks the peer is open too, otherwise null. similar_pct, which the compare page shows, is the share of the stated fields a peer shares. Peers rank by it, then by overlap_pct, then by the nearest length. Each boat carries its image (a photo URL, null when it has none).
Weekly price: each week's all-in total in currency (EUR, the default, or USD) at max guests (charter, every required fee, the platform fee; deposits never) — the figure search compares. null means the boat cannot be sold that week. weeks gives each column's check-in date. Each boat's price_currency is the currency its operator prices in; the figures themselves are in currency, and a peer's delta is the difference of the two figures in it.
Cabins are guest cabins: cabins excludes crew cabins, and crew_cabins gives those (where a boat carries its own specs). Answers are cached for up to an hour.
With id (price_mode: "delta"), anchor.price holds that boat's weekly figures and each peer holds its region, city, same and otherwise only what differs from it — make, model, and signed deltas for year ("+3"), length ("+1.0 m"), guests, berths, cabins, toilets (a peer missing the value shows null) — and its price per week is its figure minus the anchor's, except in absolute_weeks, where the anchor cannot be sold and the peer's own figure stands. With specs only (price_mode: "absolute"), every peer carries its own specs and weekly figures.
Request
Response
Errors
| Status | Body |
|---|---|
400 | Neither id nor boat_type and country, an unknown boat_type, or a country that is not a country code or name |
404 | Boat not found — the id did not resolve, or the boat is not published |
What Stands Out
/ai/boats/{id}/standoutsWhat is better and what is worse about one boat than the boats that cost the same, for one week — the get_boat_standouts tool on the MCP server and in Mermaid — each line stating how it compares, so a model never has to guess what is normal for charter. Anything the answer does not list is ordinary for boats like it. The {id} resolves as for Boat Details; authentication and rate limits are as for the search.
Similar boats are the same boat type priced within ±5% of this boat for that week (its all-in day rate for the week — every required fee and the platform fee in, the figure the search card prints), sold the same way (a day charter is compared only with day charters), in the boat's own country — every country when the country has fewer than 10. With fewer than 10 even then, nothing is compared and note says so. Without date_from the boats' "from" figures — the all-in day rate of each boat's cheapest week from today — are compared instead.
A pro is better than 90% of those boats; a con is worse than 90% of them, so the list does not lean negative. 10% is the default cut-off, and cutoff_pct moves every bar together. What is compared: build year, length, cabins, bathrooms, maximum guests, security deposit, check-in and check-out hour, kit that under 10% of them carry (and is rare across the fleet — all of it, in one line, as [n%] item), kit that 90% of them carry and this boat does not (only when its equipment list is as long as theirs and the model's other hulls mostly lack it too), a changeover day off the one 70% of them use (never Saturday), how many of the model are listed, and the last open weeks of the month or season. When nothing clears those bars, pros still names the one thing that beats the most of them and cons the one thing the most of them beat — past half of them — each with its true share; a con names the typical figure beside the boat's.
Optional date_from (YYYY-MM-DD) names the week. Optional cutoff_pct (default 10, at most 50) sets the cut-off: a pro beats all but that share of the similar boats, a con trails all but it, and rare kit is on fewer than it, across the fleet too. Optional compare_by (default price) picks the similar boats: price — within ±5% of this boat's rate, what else the same money buys; guests — the same guest count; length — within ±2.5% of its length. Each is the same boat type, priced for that week. On guests and length the all-in day rate is compared too (€211 a day, all fees in (better than 99%); without date_from, €211 a day at the cheapest week, all fees in), and the measure that picked the group is not compared with itself. Any other value is a 400. Optional fallback (default true): false leaves out the one line each side that is named when nothing clears the cut-off. Optional currency (EUR, the default, or USD) writes the security deposit and the day rate in it ($230 a day, all fees in).
Request
Response
Response Fields
| Field | Type | Description |
|---|---|---|
week | object|null | { from, to } for the week asked about, null without date_from |
compared_with | string|null | The similar boats every line is measured against, said once; null when fewer than 10 |
compare_by | string | The basis the similar boats were picked by |
cutoff_pct | number | The cut-off the answer used |
pros / cons | string[] | "fact (how it compares)". A percentage is of the boats in compared_with; where fewer of them state the value, the count follows (147 state one) |
rare_kit | object[] | The kit in the Rare kit pro as data, in the same order — most useful first, the rarer first among equals: { name, share_pct, useful_score }, where share_pct is the share of the boats in compared_with that carry it and useful_score (1-5, null for an item that matches no searchable feature) is how much it changes a charter week for the people aboard. Empty when no kit clears the cut-off or nothing was compared |
note | string | Whether the absence of a line means "ordinary", or nothing was compared |
Errors
| Status | Body |
|---|---|
400 | Boat id required, or compare_by is not price, guests or length |
404 | Boat not found — the id did not resolve, or the boat is not published |
500 | The comparison could not be made; the answer is not sent without it |