OpenYacht

Listing Wire Schema

Status: Draft v0.1 (2026-08) — designed against the listing data brokerages actually hold and exchange today, not in the abstract. Every field earns its place from the field models of working brokerage systems and from long experience integrating the industry’s listing sources; nothing here is speculative. Prerequisites: api-design.md (envelope, endpoints, error contract), yacht-identity.md (canonical URIs, authority, provenance, usage terms, media identity), federation-protocol.md (signing). Open questions for reviewers are marked ⚠ inline and collected at the end.

This document defines the field-by-field payload of a listing as served by its authority at GET /openyacht/v1/listings/{uuid} and in /listings collection responses. It fills in the structural contract fixed in api-design.md.

Conventions (normative)

These apply to every field in this document and to any future extension:

  1. Naming: snake_case, ASCII, singular for scalars, plural for arrays. Unit-bearing numeric fields carry a unit suffix (below). These rules apply identically to sale and charter listings and to every nested block.
  2. Complete objects, null for missing. Every listing payload contains every field defined here for its type. A value the authority does not have — or that the receiving partner’s sharing rules withhold (see Field groups) — is null (or [] for arrays). Consumers never branch on field presence; they MAY need to handle null anywhere except the fields marked required below.
  3. Unknown fields MUST be ignored by consumers (per api-design.md); authorities MAY add private extension fields prefixed x_ without a version bump.
  4. Units are canonical metric, one unit per concept. No imperial values, no unit fields, no formatted display strings on the wire — presentation is the consumer’s job. Suffixes: _m metres, _kg kilograms, _l litres, _kn knots, _nmi nautical miles, _lph litres per hour, _hp / _kw for engine power. gross_tonnage is dimensionless (GT). Decimals are JSON numbers.
  5. Money is never a float. Monetary amounts are strings of decimal digits with optional . separator, paired with an ISO 4217 currency code (per api-design.md). A single currency per amount: the wire carries the asking price or rate in its listing currency only. Pre-converted values are prohibited — stale conversions are a failure mode this protocol exists to eliminate; consumers convert with their own rates.
  6. Timestamps are RFC 3339 UTC (2026-08-19T16:05:00Z). Dates without time-of-day (e.g. rate validity) are YYYY-MM-DD.
  7. Rich text (descriptions[].content only — no other field accepts markup) is a restricted HTML subset: p, br, ul, ol, li, strong, em, h3, h4, a[href] (https only). No attributes besides a[href], no styles, classes, scripts, images, or tables. Authorities SHOULD strip nonconforming markup on ingest; consumers MUST sanitise before rendering regardless.
  8. Enums are closed sets defined here; an authority that cannot map a value uses null (never an invented string). Vocabulary fields (builder, category, features) are open names with optional well-known slugs — see Shared vocabulary.

Top level

FieldTypeRequiredDescription
idstring (URI)Canonical listing URI, per yacht-identity.md. Immutable.
typeenumsale | charter. Immutable. A vessel both for sale and for charter is two listings, linked by their vessel blocks.
statusenumactive | under_offer | sold | withdrawn. draft exists in the lifecycle (yacht-identity.md) but is never distributed. sold / withdrawn normally arrive as tombstones (api-design.md); the full payload form occurs only when dereferencing a terminal listing within its retention window.
updated_attimestampLast change to federation-visible state. Drives updated_since sync.
listed_attimestampWhen the listing went active.
conditionenumnew | used.
agreementobjectMandate metadata: { "type": "central" | "exclusive" | "open" | null, "co_brokerage": bool | null }. ⚠ Open question 5.
vesselobjectSee Vessel.
listingobjectCommercial block — see Listing.
specificationsobjectSee Specifications.
descriptionsarraySee Descriptions.
featuresarraySee Features.
mediaobjectTyped collections with an explicit profile image — see Media.
charterobject | nullCharter-only block; null when type is sale. See Charter.
usageobjectUsage terms, defined normatively in yacht-identity.md.
complianceobjectSee Compliance.

Vessel

Real-world identity of the physical boat, for matching (yacht-identity.md defines the semantics; this defines the shape). All fields nullable; populate everything known.

FieldTypeDescription
hinstringHull Identification Number.
imostringIMO number (permanent; large yachts).
mmsistringMMSI (follows registration — strong evidence, not proof).
official_numberstringNational registry official number.
buildervocab{ "name": "Benetti", "slug": "benetti" } — slug from the builder registry, never invented. See Shared vocabulary.
modelvocabSame shape; slug is builder-scoped, node-curated. See Shared vocabulary.
year_builtintegerYear of build (delivery).
refit_yearintegerMost recent significant refit; null if none.
loa_mnumberLength overall, metres.
previous_namesarray of stringsFormer vessel names — the matching aid for renamed boats.

Sale-listing sources commonly carry none of imo, mmsi, or previous_names; imported listings will hold null there until enriched. That is expected and correct — nulls that say “unknown” beat guesses.

Listing

The commercial facts of this mandate.

FieldTypeDescription
namestring, requiredCurrent marketing name of the vessel.
summarystringPlain-text teaser (no markup).
priceobjectSale listings: { "amount": "8500000", "currency": "EUR", "on_application": false, "starting_price": false }. amount and currency are null when on_application is true or the partner lacks the pricing field group. starting_price marks new-build/configure-to-order base prices. Charter listings: the whole object is null — charter pricing is charter.rates.
price_historyarray[{ "amount": "8900000", "currency": "EUR", "changed_at": "2026-05-01T09:00:00Z" }, …] — every asking price the listing has had, most recent first; the first entry always matches the current price. [] unless the partner has the history field group.
locationobject{ "display": "Palma de Mallorca, Spain", "city": "Palma de Mallorca", "state": null, "country": "ES", "marina": null, "coordinates": null }. country is ISO 3166-1 alpha-2. display is required within the object and is the authority’s chosen public wording. marina and coordinates ({ "lat": …, "lon": … }) require the location_exact field group.
brokersarrayOrdered, first entry is the lead broker: [{ "name": "…", "title": null, "email": "…", "phone": null, "photo_url": null }]. Brokerage identity is the authority node itself (federation-protocol.md); this block exists for attribution and contact, not identity.

Specifications

One flat object; grouped here only for readability. All fields nullable except power_or_sail (see Classification).

Dimensions & weightsbeam_m, draft_max_m, draft_min_m, lwl_m (waterline length), lod_m (length on deck), bridge_clearance_m, gross_tonnage, displacement_kg.

Capacitiesfuel_capacity_l, water_capacity_l, holding_tank_l.

Performancecruise_speed_kn, max_speed_kn, range_nmi (at cruise), fuel_consumption_lph (at cruise).

Constructionhull_material, superstructure_material, deck_material, hull_shape (enum: planing | semi_displacement | displacement | hydrofoil | catamaran | trimaran), hull_color, naval_architect, exterior_designer, interior_designer, fuel_type (enum: diesel | petrol | electric | hybrid), flag (country name as registered), registry_port.

Classificationpower_or_sail (enum: power | sail, required — the one non-nullable specification: every listing can state it, and consumers’ top-level inventory split depends on it) and category (vocab: { "name": "Motor Yacht", "slug": "motor-yacht" }). Two independent axes, per the production systems. The category vocabulary is a fixed registry at ../registry/categories.json, governed like the builder registry — see Shared vocabulary → The category registry.

Accommodations — totals plus two linked but distinct structured breakdowns, and a separate crew block (industry consultation, 2026-08: cabin configuration and bed configuration are different facts, and crew capacity hides real variation when flattened to one number):

  • Totals: cabins, sleeps, heads, guests_cruising, guests_entertaining.
  • Guest capacities are three separate facts: sleeps is the overnight capacity; guests_cruising is the number carried under way (the regulatory limit for a commercially registered yacht — commonly 12); guests_entertaining is the number carried while static, at anchor or dockside — typically much higher, and quoted routinely for events. Collapsing them loses real information: a yacht sleeping 11 and cruising 12 may entertain 35. All are nullable — an authority without a figure sends null rather than repeating another. (They live here rather than in the charter block because they are facts about the vessel — sale listings quote them too.)
  • cabin_config — room-level properties: { "double": 4, "twin": null, "triple": null, "single": 0, "convertible": 3 }. A convertible cabin is one that can be split/re-made (the room count is fixed; how it presents is not).
  • berth_config — sleeping surfaces in the typical/default makeup: { "king": 4, "queen": 3, "double": null, "twin": null, "single": null, "pullman": 0, "bunk": null }. Because convertibles change presentation (4 double + 3 convertible cabins can present as 4 king + 3 queen or 4 king + 6 twin), berth_config describes the default; per-booking makeup is out of scope.
  • crew_accommodation{ "cabins": 2, "berths": 4, "layout": "2 rooms, 2 sets of bunks" } (replaces flat crew counts; a stated “4 crew berths” can hide e.g. captain and partner sharing one cabin — room count and layout are the operationally asked questions).

All fields nullable. Free-text bed descriptions beyond layout go in descriptions, not here.

Machinery — two arrays:

"engines": [
  {
    "make": "Volvo", "model": "D11", "year": 2016,
    "type": "inboard", "drive_type": "Direct",
    "power_hp": 675, "power_kw": 503.35,
    "fuel_type": "diesel", "hours": 777,
    "hours_recorded_at": "2024-03-05", "location": "starboard"
  }
],
"generators": [
  { "make": "Onan", "model": null, "power_kw": 21, "hours": 550, "hours_recorded_at": null }
]

engines[].type: inboard | outboard | saildrive | pod | jet. Engine serial numbers are deliberately not on the wire (private annotation territory).

Tenders & toys (sale)tenders: free-text string. Structure here is unproven in every source; do not invent it. Structured water toys belong in features.

Descriptions

Ordered array of rich-text sections:

"descriptions": [
  { "section": "overview", "content": "<p>…</p>" },
  { "section": "highlights", "content": "<ul><li>…</li></ul>" },
  { "section": "Notable Upgrades", "content": "<p>…</p>" }
]
  • section is a label, not an enum; the well-known values overview and highlights SHOULD be used where they apply so consumers can place them predictably.
  • content follows the restricted-HTML rule (Conventions §7).
  • Order is the authority’s intended display order.

Features

The normalised equipment/amenity list — one mechanism for sale amenities, charter toys, and the feed checkbox flags:

"features": [
  { "category": "comfort", "name": "Air conditioning", "slug": "air-conditioning", "quantity": null },
  { "category": "deck", "name": "Elevator", "slug": "elevator", "quantity": null },
  { "category": "toys", "name": "Seabob", "slug": "seabob", "quantity": 2 },
  { "category": "toys", "name": "Yachtwerft Meyer limousine tender", "slug": null, "quantity": null }
]
  • name is required free text; slug is an optional well-known identifier; category is an optional grouping label.
  • quantity is an integer count ≥ 1, or null meaning “present, count unstated” — the common case. When a count is stated, name stays the singular vocabulary term ("Seabob", never "2x Seabob") so slug matching and name fallback keep working; the count lives in quantity alone. (Added from implementer feedback, 2026-08: real inventories carry per-toy counts, and importers were reduced to scraping them out of description text — an explicit number on the wire is unambiguous.)
  • Rationale (production-proven): aggregator feeds deliver equipment as broker-filled free text plus a handful of structured checkboxes; extracting structure from the text is an import problem, never a wire problem. A federation node’s own listings are first-party data — structured at entry. Feed checkbox flags (elevator, helipad, flybridge, stabilisers, thrusters, wheelchair access, …) map to well-known slugs rather than dedicated top-level fields, so there is exactly one place to look.
  • The well-known slug list is published, non-normatively, at ../registry/features.json, with its curation rules in ../registry/README.md (amended 2026-08: the list now exists; this previously said it was “maintained alongside the other vocabulary curation material”). Unlike the builder/category/destination registries it is never a validation gate: an unlisted feature is sent with slug: null, an unknown slug is not an error, and promotion to a fixed registry — slug registry governance — remains a v1.x decision (api-design.md).

Media

media is an object of typed collections, not one mixed array — a gallery image, a GA plan, a walkthrough video, and a brochure PDF are consumed differently, so they are delivered separately:

"media": {
  "profile":   { "url": "", "sha256": "", "width": 4000, "height": 2667, "caption": "", "thumbnail_url": "" },
  "gallery":   [ { "url": "", "sha256": "", "category": "exterior", "width":, "height":, "caption": "", "sort": 1 } ],
  "layouts":   [ { "url": "", "sha256": "", "width":, "height":, "caption": "GA", "sort": 1 } ],
  "videos":    [ { "url": "https://vimeo.com/…", "sha256": null, "caption": "Walkthrough", "sort": 1 } ],
  "tours":     [ { "url": "", "caption": null, "sort": 1 } ],
  "documents": [ { "url": "", "sha256": "", "caption": "Sample menu", "sort": 1 } ]
}
  • profile is a dedicated, explicit hero image — a single image object, null only if the listing genuinely has no imagery. Consumers MUST use it as the listing’s representative image and MUST NOT fall back to “first gallery entry” when it is present. Rationale: source systems order their image lists for their own purposes — print layout, upload order, category grouping — and none of those reliably surfaces the best representative shot, so “use the first image” conventions routinely produce a listing represented by a deck plan or a detail shot. The authority chooses the hero shot, once, explicitly. The profile image need not be repeated in gallery.
  • gallery[].category: exterior | interior | lifestyle | crew | null. Order within the gallery is sort.
  • layouts holds GA/deck plans as images; plan PDFs belong in documents.
  • videos and tours MAY point to external platforms (YouTube, Vimeo, Matterport…) — sha256 is null for external URLs, required for files hosted by the authority.
  • Hashing, caching, usage inheritance, and URL-serving rules for all image/document files are defined in yacht-identity.md. Each item carries one URL — the best resolution the receiving partner is granted; derived sizes are not on the wire, partners generate their own. Rationale (settled by industry consultation, 2026-08): feeds that ship multiple pre-generated sizes never agree on a standard size set, so multi-source consumers end up resizing anyway; one large image resized to the receiver’s own needs (locally or via an on-the-fly image CDN) is the simpler contract. Hi-res originals are gated by the media_original field group; without it the URL points to the authority’s watermarked/derived rendition, with sha256, width, height describing that file.
  • The one exception: profile.thumbnail_url — a small, authority-served rendition of the profile image, for share cards, listing pickers, and other contexts where a consumer reasonably wants a preview without running an image pipeline. Required (non-null) whenever profile is present. No dimensions are mandated; ~400–640 px on the long edge is the sensible range. It inherits the listing’s usage terms like any cached media.
  • Placeholders are prohibited. A listing with no imagery has media.profile: null — an authority MUST NOT serve a generated blank/placeholder image in its place. Rationale: a synthesised placeholder is indistinguishable from real imagery to a consumer, so “does this listing have a photograph?” stops being answerable programmatically — and a consumer that cannot answer it cannot filter on it, rank by it, or chase the gap with the authority. null keeps the question machine-checkable: it means no image, and anything non-null is a real photograph or rendering of the vessel.
  • documents (brochures, plan PDFs, sample menus) requires the documents field group; without it the array is served as [] (a URL you may not use is worse than no entry).

Charter

Present only when type is charter; everything outside this block is identical to a sale listing.

"charter": {
  "rates": [
    {
      "season": "summer",
      "rate_type": "weekly",
      "amount_min": "435000",
      "amount_max": "480000",
      "currency": "EUR",
      "contract_terms": "MYBA",
      "apa_percent": 30,
      "vat_percent": null,
      "valid_from": "2026-05-01",
      "valid_to": "2026-09-30"
    }
  ],
  "operating_areas": [ { "name": "Western Mediterranean", "slug": "western-mediterranean", "season": "summer" } ],
  "summer_base_port": "Palma de Mallorca",
  "winter_base_port": "Antigua",
  "crew": [
    {
      "role": "Captain", "name": "", "nationality": "British",
      "bio": "", "photo_url": "https://…", "tba": false
    }
  ]
}
  • Guest capacities (sleeps, guests_cruising, guests_entertaining) live in specifications — they are facts about the vessel, not the charter offer. See Specifications → Accommodations.
  • rates[].season: summer | winter | a free-text label for special periods (regattas, holidays). rate_type: weekly | daily. A fixed single rate uses amount_min = amount_max. contract_terms names the charter contract form (e.g. MYBA); apa_percent and vat_percent are numbers, not strings. Rates require the pricing field group.
  • operating_areas[] uses the vocab shape (name required, slug optional) plus an optional season. Slugs come from the destination registry (../registry/destinations.json), governed exactly like the builder and category registries — see Shared vocabulary → The destination registry.
  • crew[] entries with tba: true represent an unannounced position (name et al. null). Crew bios and photos are personal data: they inherit the listing’s usage terms, including expires_with_listing. The distributing authority MUST hold an attestation from the charter manager or captain that they are authorised to publish the crew data (industry consultation, 2026-08: per-crew-member consent is not collectible at crew-turnover pace and was never the practice; the attestation standard is the honest, auditable one).
  • Availability calendars and bookings are out of protocol scope for v1 (see the scope boundaries in the repository README.md).

Usage

Normative definition in yacht-identity.md. Shape, for completeness: display, attribution_required, attribution_text, marketing_materials, ai_indexing, expires_with_listing — all booleans except attribution_text (string).

Compliance

FieldTypeDescription
not_for_sale_to_us_residents_in_us_watersboolStandard across the sale-listing systems in common use.
vat_statusstringFree text, not an enum (industry consultation, 2026-08: a wrong fixed list is worse than an unstructured field — it makes every listing using it assert a legally meaningless claim). paid, not_paid, exempt are suggested values, not enforced. A closed enum is deferred until reviewed by someone with real EU/UK VAT and US sales/use-tax expertise. ⚠ Open question 3 remains open for that expert list.
ce_certifiedbool
mca_compliantbool
classificationarray[{ "society": "RINA", "notation": "Pleasure", "next_survey_due": "2027-04-01" }].

Field groups (gating map)

yacht-identity.md defines the field groups an authority grants per partner. The payload a partner receives is already filtered; withheld values are null/[]/omitted per this map:

Field groupWithholding nulls/removes
pricinglisting.price.amount+currency, charter.rates
location_exactlisting.location.marina, listing.location.coordinates
media_originalswaps hi-res URLs for derived/watermarked renditions
documentsmedia.documents served as []
vessel_identifiersvessel.hin, vessel.imo, vessel.mmsi, vessel.official_number
historylisting.price_history

Shared vocabulary

builder, model, category, features[], and operating_areas[] share one shape: { "name": string (required), "slug": string | null }. Names are display truth; slugs are the interoperability mechanism. A consumer that does not recognise a slug falls back to the name.

The builder registry

Builders are the vocabulary where free text demonstrably fails (broker-typed variants make cross-node search degrade into string matching), and they change slowly — so they get a fixed registry:

  • The registry is a versioned file — the set list both sides validate against — maintained in the spec repository at registry/builders.json and published canonically at https://openyacht.org/registry/builders.json. Each entry: { "slug", "name", "country" }. Versioning is date-based (2026.08.0), tzdata-style.
  • Nodes vendor a copy and update it out-of-band (with software updates, or a periodic fetch of the published file). The registry is NEVER fetched at request time — validating or serving a listing MUST NOT depend on any third-party host, including openyacht.org.
  • Outgoing (authority): vessel.builder.slug MUST be a slug present in the registry, validated at data entry. Authorities MUST NOT invent slugs — a non-null slug on the wire is a claim of registry membership. A genuinely unlisted builder is sent as { "name": "…", "slug": null }; the remedy is a registry pull request, not an improvised slug. When the registry catches up, the authority fills the slug on its next update, which propagates like any edit — no migration needed.
  • Incoming (consumer): validate a non-null slug against the vendored registry. Known slug → use it (and prefer the registry’s canonical name for display/grouping). Unknown slug → the sender is on a newer registry version (or in error): fall back to the name, flag the registry copy for update. slug: null → store the free-text name as-is and flag for human curation. Consumers MUST NOT invent mappings.
  • The registry is deliberately alias-free: it is a clean validation list, not a normalisation table. Alias matching (“Sunseeker International” → sunseeker) exists only where dirty data enters a node — feed import connectors — and it belongs to that connector: an implementer importing from a given source maintains whatever mapping that source’s dirt requires. It is not protocol data, and no central alias list is published — the mappings a connector needs depend entirely on which sources it reads. Between OpenYacht nodes there is nothing to normalise: both ends validated against the same list before the data ever went on the wire.
  • Governance is pull requests to the spec repository — no runtime service, no consensus protocol.

The category registry

specifications.category follows the builder registry’s model exactly, for the same reason: categories are how consumers group and filter cross-node inventory, and free-text categories degrade that into string matching. The registry is registry/categories.json (published canonically at https://openyacht.org/registry/categories.json). Each entry: { "slug", "name" }; versioning, vendoring, outgoing/incoming validation, and governance are all as for builders — authorities MUST NOT invent category slugs, an uncategorisable listing is sent as { "name": "…", "slug": null } (or category: null), and the remedy for a missing category is a registry pull request. Unlike builders, the category list is small and near-closed; additions should be rare and argued from real inventory that no existing category fits. Category names are singular, and category slugs MUST NOT encode the power_or_sail axis — power vs sail is already a separate, required field, so the registry carries one catamaran, never a power/sail pair.

The destination registry

charter.operating_areas[] follows the same model: cruising grounds are how charter consumers filter and group inventory, and free-text destinations degrade that into string matching (“W. Med”, “West Med”, “Western Mediterranean”). The registry is registry/destinations.json (published canonically at https://openyacht.org/registry/destinations.json). Each entry: { "slug", "name", "parent" }; versioning, vendoring, outgoing/incoming validation, and governance are all as for builders — authorities MUST NOT invent destination slugs, an unlisted cruising ground is sent as { "name": "…", "slug": null }, and the remedy is a registry pull request.

Two things are specific to destinations:

  • The list is hierarchical, and every level is a valid value. parent holds another entry’s slug, or null for a top-level region. A yacht that ranges across a whole region references the region entry (western-mediterranean); one that works a single stretch references the specific entry (french-riviera-monaco). Authorities SHOULD send the most specific entry that is true, and MUST NOT send a parent and its own descendants in the same array — the parent already implies them.
  • Consumers MUST NOT infer containment from slug spelling. Resolve parent against the vendored registry instead; slugs are opaque identifiers, and a hierarchy read out of a string is a hierarchy that breaks on the first entry that does not follow the pattern.

Groupings follow charter-industry convention rather than strict geography — a broker looking for “Western Mediterranean” expects the Balearics and the French Riviera in it, whichever way an atlas divides the sea. Names are unique across the whole list, at every level, so the fall-back-to-name rule stays unambiguous.

Models

Models are deliberately not registered in v1: new models appear far too often for a central list to stay current, and a stale registry would train implementers to leave slugs null. Instead:

  • A model exists only in relation to a builder: vessel.model.slug is builder-scoped (unique within that builder’s namespace, meaningful only alongside vessel.builder.slug). Each authority curates its own model list.
  • Cross-node matching relies on the builder anchor: once both sides agree on builder.slug, model names are a small, tractable comparison — and vessel matching on builder + model + year_built + loa_m already requires human confirmation per yacht-identity.md, never automation.
  • If registry governance expands (v1.x), per-builder model lists can join builders.json entries without any wire change.

Implementation guidance (non-normative)

Data entry for builders should be a fixed choice from the vendored registry plus an explicit “unlisted builder” escape hatch — not a free-text field. Normalising dirty source data is exclusively an import-connector concern (Phase 2): connectors map source strings to registry slugs using their own mapping, with one rule of thumb worth stating because it is easy to get wrong and expensive to discover late: where a source offers both a constrained and a free-text field for the same concept, read the constrained one. It is clean by construction; the free-text field beside it accumulates typing variants that then need normalising for no reason. A connector that reads the wrong one builds a large alias burden that was never necessary — and the mappings, once written, hide the mistake. Guard against generic placeholders (Custom, Other, Unknown) in the constrained field by falling back to the free-text value for those entries only. Names a connector cannot match are the natural curation queue for its own mapping, and the source of registry additions; canonical-name policy and casing rules are in ../registry/README.md.

Complete example (sale)

{
  "id": "https://authority.example/openyacht/v1/listings/018f6d2e-9f0a-7cc3-a1b2-3c4d5e6f7a8b",
  "type": "sale",
  "status": "active",
  "updated_at": "2026-08-19T16:05:00Z",
  "listed_at": "2026-02-04T15:06:12Z",
  "condition": "used",
  "agreement": { "type": "central", "co_brokerage": true },
  "vessel": {
    "hin": "OEOM5021G516",
    "imo": null,
    "mmsi": null,
    "official_number": "1261706",
    "builder": { "name": "Maritimo", "slug": "maritimo" },
    "model": { "name": "M50", "slug": null },
    "year_built": 2016,
    "refit_year": null,
    "loa_m": 15.24,
    "previous_names": []
  },
  "listing": {
    "name": "BLUE SKY",
    "summary": "BLUE SKY, the 50’ Maritimo, offers an unparalleled yachting experience…",
    "price": { "amount": "1388000", "currency": "USD", "on_application": false, "starting_price": false },
    "price_history": [
      { "amount": "1388000", "currency": "USD", "changed_at": "2025-05-15T17:37:01Z" },
      { "amount": "1450000", "currency": "USD", "changed_at": "2025-02-04T15:06:12Z" }
    ],
    "location": {
      "display": "Seattle, Washington, United States",
      "city": "Seattle", "state": "Washington", "country": "US",
      "marina": null, "coordinates": null
    },
    "brokers": [
      { "name": "Alex Marlow", "title": "Senior Broker", "email": "[email protected]", "phone": "+1 555 0100", "photo_url": null }
    ]
  },
  "specifications": {
    "beam_m": 5.05, "draft_max_m": 1.27, "draft_min_m": null,
    "lwl_m": null, "lod_m": null, "bridge_clearance_m": null,
    "gross_tonnage": null, "displacement_kg": null,
    "fuel_capacity_l": 3997.39, "water_capacity_l": 798.72, "holding_tank_l": 200.63,
    "cruise_speed_kn": 20, "max_speed_kn": 29, "range_nmi": null, "fuel_consumption_lph": null,
    "hull_material": "Fiberglass", "superstructure_material": null, "deck_material": null,
    "hull_shape": "planing", "hull_color": null,
    "naval_architect": null, "exterior_designer": null, "interior_designer": null,
    "fuel_type": "diesel", "flag": "United States", "registry_port": null,
    "power_or_sail": "power", "category": { "name": "Motor Yacht", "slug": "motor-yacht" },
    "cabins": 2, "sleeps": 4, "heads": 2, "guests_cruising": null, "guests_entertaining": null,
    "cabin_config": { "double": null, "twin": null, "triple": null, "single": null, "convertible": null },
    "berth_config": { "king": null, "queen": null, "double": null, "twin": null, "single": null, "pullman": null, "bunk": null },
    "crew_accommodation": { "cabins": null, "berths": null, "layout": null },
    "engines": [
      { "make": "Volvo", "model": "D11", "year": 2016, "type": "inboard", "drive_type": "Direct", "power_hp": 675, "power_kw": 503.35, "fuel_type": "diesel", "hours": 777, "hours_recorded_at": null, "location": "starboard" },
      { "make": "Volvo", "model": "D11", "year": 2016, "type": "inboard", "drive_type": "Direct", "power_hp": 675, "power_kw": 503.35, "fuel_type": "diesel", "hours": 777, "hours_recorded_at": null, "location": "port" }
    ],
    "generators": [
      { "make": "Onan", "model": null, "power_kw": 21, "hours": 550, "hours_recorded_at": null }
    ],
    "tenders": null
  },
  "descriptions": [
    { "section": "overview", "content": "<p>BLUE SKY, the 50’ Maritimo, is a well-maintained, extensively upgraded motor yacht…</p>" }
  ],
  "features": [
    { "category": "equipment", "name": "Water maker", "slug": "water-maker", "quantity": null },
    { "category": "comfort", "name": "Air conditioning", "slug": "air-conditioning", "quantity": null }
  ],
  "media": {
    "profile": { "url": "https://authority.example/media/listings/018f6d2e-9f0a-7cc3-a1b2-3c4d5e6f7a8b/profile.jpg", "sha256": "ef6d847c6ea6004cea1b1f1f9626c6ce4088442d63dde6e08fc404a822cf4c13", "width": 4000, "height": 2667, "caption": "Profile", "thumbnail_url": "https://authority.example/media/listings/018f6d2e-9f0a-7cc3-a1b2-3c4d5e6f7a8b/profile-thumb.jpg" },
    "gallery": [
      { "url": "https://authority.example/media/listings/018f6d2e-9f0a-7cc3-a1b2-3c4d5e6f7a8b/02.jpg", "sha256": "78a5b7b942ebaa3ffd8f54b695bda9619787b0ebd7ac7fffc29642e5d62a9695", "category": "exterior", "width": 4000, "height": 2667, "caption": "Aft deck", "sort": 1 }
    ],
    "layouts": [],
    "videos": [
      { "url": "https://www.youtube.com/watch?v=TRH6e_-jtVU", "sha256": null, "caption": "Video walkthrough", "sort": 1 }
    ],
    "tours": [],
    "documents": []
  },
  "charter": null,
  "usage": {
    "display": true,
    "attribution_required": true,
    "attribution_text": "Listing courtesy of Example Yacht Brokerage",
    "marketing_materials": true,
    "ai_indexing": true,
    "expires_with_listing": true
  },
  "compliance": {
    "not_for_sale_to_us_residents_in_us_waters": false,
    "vat_status": null,
    "ce_certified": null,
    "mca_compliant": null,
    "classification": []
  }
}

A charter listing differs only in type, listing.price (null), and a populated charter block as shown in Charter.

Open questions (v0.1 → v0.2)

Five design decisions went out for industry consultation (../consultation/open-questions.md is the sendable document; responses collect privately and accepted changes land here as amendments carrying a consultation note). Status after the first consultation response (2026-08):

  1. Berth countsresolved: structured, as two linked schemas (cabin_config vs berth_config) plus a separate crew_accommodation block. Applied above.
  2. Media single-URL ruleresolved (maintainer ruling from direct feed-integration experience, plus one confirming response): single large image per item, receiver resizes to its own needs; providers’ pre-generated size sets never standardise across sources. One exception applied: profile.thumbnail_url, mandatory whenever imagery exists, for share/preview contexts; generated placeholder images are prohibited (profile: null is the only correct “no image”).
  3. vat_status valuesprovisionally resolved as free text with suggested tags; the closed enum remains open pending genuine VAT/sales-tax expertise.
  4. Charter crew personal dataresolved: charter-manager/captain attestation replaces per-crew-member consent. Applied above.
  5. agreement blockopen, with a counter-proposal on the table: drop agreement details from the wire and carry only a self-attested “Central Agency on file” flag. Awaiting further responses before deciding.