{"openapi":"3.1.0","info":{"title":"CruiseFeed API","description":"Structured cruise inventory: lines, ships, sailings, ports, itineraries, pricing.","version":"1.8.0"},"servers":[{"url":"https://api.cruisefeed.io","description":"Production"}],"paths":{"/healthz":{"get":{"tags":["Operational"],"summary":"Health check","operationId":"health","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Health"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too Many Requests","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/cruises":{"get":{"tags":["Cruises"],"summary":"List & filter cruises","description":"Filtered, paginated sailings.\n\nTwo independent freshness bounds apply, and both are reported in headers:\n\n* `X-Data-As-Of` — the newest data your plan is entitled to. Snapshot sees the\n  catalogue as it stood on the 1st of the month, Sync as of Monday; other plans are\n  live. This is an upper bound on when a sailing's content last changed.\n* `X-Data-Seen-Since` — the staleness floor. Sailings no scraper has observed since\n  this date are excluded from `items` and from `total`, so a listing never offers\n  inventory we have stopped being able to confirm. Reads `any` when no floor is\n  configured, which is the current default: nothing is being excluded on that basis.\n\nThey are measured on different things (when content last changed, versus when we\nlast saw the sailing at all), so they never cancel out: a price that has been stable\nfor months is old enough for the Snapshot tier and still freshly observed.","operationId":"listCruises","parameters":[{"name":"cruise_line","in":"query","required":false,"schema":{"type":"string","title":"Cruise Line"}},{"name":"ship_name","in":"query","required":false,"schema":{"type":"string","description":"Vessel name. Matched on the normalized name, so spelling variants agree: 'Volendam', 'ms Volendam' and 'MS VOLENDAM' return the same sailings, as do 'Scenic Eclipse II' and 'Scenic Eclipse 2'.","title":"Ship Name"},"description":"Vessel name. Matched on the normalized name, so spelling variants agree: 'Volendam', 'ms Volendam' and 'MS VOLENDAM' return the same sailings, as do 'Scenic Eclipse II' and 'Scenic Eclipse 2'."},{"name":"embark_port","in":"query","required":false,"schema":{"type":"string","description":"Substring match on the embarkation port.","title":"Embark Port"},"description":"Substring match on the embarkation port."},{"name":"region","in":"query","required":false,"schema":{"type":"string","description":"Substring match on the region. The value is canonicalized first, so `Mittelmeer` and `Mediterranean` find the same sailings. Rows whose source published something that is not a region (a route, a sailing title, a product category) store null and match nothing.","title":"Region"},"description":"Substring match on the region. The value is canonicalized first, so `Mittelmeer` and `Mediterranean` find the same sailings. Rows whose source published something that is not a region (a route, a sailing title, a product category) store null and match nothing."},{"name":"departure_from","in":"query","required":false,"schema":{"type":"string","format":"date","title":"Departure From"}},{"name":"departure_to","in":"query","required":false,"schema":{"type":"string","format":"date","title":"Departure To"}},{"name":"min_price","in":"query","required":false,"schema":{"type":"number","title":"Min Price"}},{"name":"max_price","in":"query","required":false,"schema":{"type":"number","title":"Max Price"}},{"name":"min_nights","in":"query","required":false,"schema":{"type":"integer","title":"Min Nights"}},{"name":"max_nights","in":"query","required":false,"schema":{"type":"integer","title":"Max Nights"}},{"name":"round_trip","in":"query","required":false,"schema":{"type":"boolean","title":"Round Trip"}},{"name":"cabin_category","in":"query","required":false,"schema":{"type":"string","description":"Keep only sailings that offer a fare in this cabin category: interior, oceanview, balcony, suite, other. The category is the comparable bucket for each fare's `cabin_class`, which the operator writes in their own words (543 distinct spellings across the catalogue for four rooms) and which is left exactly as they wrote it. A sailing matches if ANY of its fares is in the category, so `cabin_category=balcony` is 'this sailing sells a balcony', not 'its lead-in price is for a balcony'.","title":"Cabin Category"},"description":"Keep only sailings that offer a fare in this cabin category: interior, oceanview, balcony, suite, other. The category is the comparable bucket for each fare's `cabin_class`, which the operator writes in their own words (543 distinct spellings across the catalogue for four rooms) and which is left exactly as they wrote it. A sailing matches if ANY of its fares is in the category, so `cabin_category=balcony` is 'this sailing sells a balcony', not 'its lead-in price is for a balcony'.","example":"balcony"},{"name":"dedupe","in":"query","required":false,"schema":{"type":"boolean","description":"Collapse the same sailing across sources to one row (first-party source wins)","default":true,"title":"Dedupe"},"description":"Collapse the same sailing across sources to one row (first-party source wins)"},{"name":"seen_since","in":"query","required":false,"schema":{"type":"string","format":"date","description":"Only sailings OBSERVED at the source on or after this date (YYYY-MM-DD), matching the `last_seen_at` on each row. This is the freshness filter: `scraped_at` records when a row last CHANGED, so a stable sailing keeps an old `scraped_at` however recently it was re-verified. Roughly a quarter of the catalogue is a frozen legacy import that nothing refreshes; `seen_since` is how you exclude it deliberately rather than by using `min_price` as a proxy.","title":"Seen Since"},"description":"Only sailings OBSERVED at the source on or after this date (YYYY-MM-DD), matching the `last_seen_at` on each row. This is the freshness filter: `scraped_at` records when a row last CHANGED, so a stable sailing keeps an old `scraped_at` however recently it was re-verified. Roughly a quarter of the catalogue is a frozen legacy import that nothing refreshes; `seen_since` is how you exclude it deliberately rather than by using `min_price` as a proxy.","example":"2026-08-01"},{"name":"has_booking_url","in":"query","required":false,"schema":{"type":"boolean","description":"Restrict to sailings that do (`true`) or do not (`false`) carry a `booking_url`. About 26% of the catalogue has one — most lines never expose a deep link — so use this when a bookable link is required and expect a much smaller result set. `detail_url` is the more widely populated link at ~59%.","title":"Has Booking Url"},"description":"Restrict to sailings that do (`true`) or do not (`false`) carry a `booking_url`. About 26% of the catalogue has one — most lines never expose a deep link — so use this when a bookable link is required and expect a much smaller result set. `detail_url` is the more widely populated link at ~59%."},{"name":"include_past","in":"query","required":false,"schema":{"type":"boolean","description":"Include sailings that have already departed. Off by default, so a plain listing returns upcoming sailings only. Ignored when departure_from or departure_to is set — an explicit date bound always wins.","default":false,"title":"Include Past"},"description":"Include sailings that have already departed. Off by default, so a plain listing returns upcoming sailings only. Ignored when departure_from or departure_to is set — an explicit date bound always wins."},{"name":"sort","in":"query","required":false,"schema":{"type":"string","description":"One of: departure_date, -departure_date, price, -price","default":"departure_date","title":"Sort"},"description":"One of: departure_date, -departure_date, price, -price"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"description":"Max rows to return.","default":50,"title":"Limit"},"description":"Max rows to return."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Rows to skip.","default":0,"title":"Offset"},"description":"Rows to skip."},{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CruisePage"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/cruises.csv":{"get":{"tags":["Cruises"],"summary":"Export cruises as CSV","description":"Same filters as /cruises; flat CSV for delivery/export (itinerary omitted).\n\n`limit`/`offset` page the export like /cruises; omitting `limit` exports the\nfull filtered set (still bounded by the caller's plan row cap).\n\nThe same staleness floor as /cruises applies: sailings no scraper has observed since\nthe date in the `X-Data-Seen-Since` header are not exported. The header reads `any`\nwhen no floor is configured, which is the current default.","operationId":"exportCruisesCsv","parameters":[{"name":"cruise_line","in":"query","required":false,"schema":{"type":"string","title":"Cruise Line"}},{"name":"ship_name","in":"query","required":false,"schema":{"type":"string","description":"Vessel name. Matched on the normalized name, so spelling variants agree: 'Volendam', 'ms Volendam' and 'MS VOLENDAM' return the same sailings, as do 'Scenic Eclipse II' and 'Scenic Eclipse 2'.","title":"Ship Name"},"description":"Vessel name. Matched on the normalized name, so spelling variants agree: 'Volendam', 'ms Volendam' and 'MS VOLENDAM' return the same sailings, as do 'Scenic Eclipse II' and 'Scenic Eclipse 2'."},{"name":"embark_port","in":"query","required":false,"schema":{"type":"string","title":"Embark Port"}},{"name":"region","in":"query","required":false,"schema":{"type":"string","title":"Region"}},{"name":"departure_from","in":"query","required":false,"schema":{"type":"string","format":"date","title":"Departure From"}},{"name":"departure_to","in":"query","required":false,"schema":{"type":"string","format":"date","title":"Departure To"}},{"name":"min_price","in":"query","required":false,"schema":{"type":"number","title":"Min Price"}},{"name":"max_price","in":"query","required":false,"schema":{"type":"number","title":"Max Price"}},{"name":"min_nights","in":"query","required":false,"schema":{"type":"integer","title":"Min Nights"}},{"name":"max_nights","in":"query","required":false,"schema":{"type":"integer","title":"Max Nights"}},{"name":"round_trip","in":"query","required":false,"schema":{"type":"boolean","title":"Round Trip"}},{"name":"cabin_category","in":"query","required":false,"schema":{"type":"string","description":"Keep only sailings that offer a fare in this cabin category: interior, oceanview, balcony, suite, other. The category is the comparable bucket for each fare's `cabin_class`, which the operator writes in their own words (543 distinct spellings across the catalogue for four rooms) and which is left exactly as they wrote it. A sailing matches if ANY of its fares is in the category, so `cabin_category=balcony` is 'this sailing sells a balcony', not 'its lead-in price is for a balcony'.","title":"Cabin Category"},"description":"Keep only sailings that offer a fare in this cabin category: interior, oceanview, balcony, suite, other. The category is the comparable bucket for each fare's `cabin_class`, which the operator writes in their own words (543 distinct spellings across the catalogue for four rooms) and which is left exactly as they wrote it. A sailing matches if ANY of its fares is in the category, so `cabin_category=balcony` is 'this sailing sells a balcony', not 'its lead-in price is for a balcony'.","example":"balcony"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Max rows to export. Omit to export every matching row (subject to your plan's row cap).","title":"Limit"},"description":"Max rows to export. Omit to export every matching row (subject to your plan's row cap)."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Rows to skip before exporting.","default":0,"title":"Offset"},"description":"Rows to skip before exporting."},{"name":"dedupe","in":"query","required":false,"schema":{"type":"boolean","description":"Collapse the same sailing to one row","default":true,"title":"Dedupe"},"description":"Collapse the same sailing to one row"},{"name":"seen_since","in":"query","required":false,"schema":{"type":"string","format":"date","description":"Only sailings OBSERVED at the source on or after this date (YYYY-MM-DD), matching the `last_seen_at` on each row. This is the freshness filter: `scraped_at` records when a row last CHANGED, so a stable sailing keeps an old `scraped_at` however recently it was re-verified. Roughly a quarter of the catalogue is a frozen legacy import that nothing refreshes; `seen_since` is how you exclude it deliberately rather than by using `min_price` as a proxy.","title":"Seen Since"},"description":"Only sailings OBSERVED at the source on or after this date (YYYY-MM-DD), matching the `last_seen_at` on each row. This is the freshness filter: `scraped_at` records when a row last CHANGED, so a stable sailing keeps an old `scraped_at` however recently it was re-verified. Roughly a quarter of the catalogue is a frozen legacy import that nothing refreshes; `seen_since` is how you exclude it deliberately rather than by using `min_price` as a proxy.","example":"2026-08-01"},{"name":"has_booking_url","in":"query","required":false,"schema":{"type":"boolean","description":"Restrict to sailings that do (`true`) or do not (`false`) carry a `booking_url`. About 26% of the catalogue has one — most lines never expose a deep link — so use this when a bookable link is required and expect a much smaller result set. `detail_url` is the more widely populated link at ~59%.","title":"Has Booking Url"},"description":"Restrict to sailings that do (`true`) or do not (`false`) carry a `booking_url`. About 26% of the catalogue has one — most lines never expose a deep link — so use this when a bookable link is required and expect a much smaller result set. `detail_url` is the more widely populated link at ~59%."},{"name":"include_past","in":"query","required":false,"schema":{"type":"boolean","description":"Include sailings that have already departed. Off by default, matching /v1/cruises. Ignored when departure_from or departure_to is set.","default":false,"title":"Include Past"},"description":"Include sailings that have already departed. Off by default, matching /v1/cruises. Ignored when departure_from or departure_to is set."},{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/cruises/{cruise_id}":{"get":{"tags":["Cruises"],"summary":"Get a cruise by id","operationId":"getCruise","parameters":[{"name":"cruise_id","in":"path","required":true,"schema":{"type":"string","title":"Cruise Id"}},{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CruiseDetail"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/cruises/{cruise_id}/history":{"get":{"tags":["Cruises"],"summary":"Get a cruise's price history","description":"Daily price/availability timeline for one sailing (only days a value changed).\n\nResolved at the level of the SAILING, not of the record: every observation we hold\nfor this ship leaving this port on this day at this length is returned, whoever\nrecorded it. De-duplicating two records of one sailing would otherwise shorten its\ntimeline to whichever record won — on production that hides 47.6% of all the price\npoints we hold.\n\nThe sailing is read straight off the history rows (`cruise_price_history.sailing_key`,\nmaterialized at write time), not resolved through `cruises` at read time. One indexed\nlookup, and — the reason it matters far more than the latency — a timeline that no\nlonger depends on the de-duplication losers still being in the table.\n\nObservations from different records are NOT interchangeable and are not merged into\none line: they can be quoted in different currencies and for different lead-in\ncabins. Each point therefore carries `series`, the id of the record that observed it;\npoints sharing a `series` are comparable with each other and nothing else. The\n`series` matching this response's `id` is the one behind the price the listing serves.\nPoints are ordered by `snapshot_date`, then by source precedence — see\n`_point_order`, which explains why that is computed from the point rather than looked\nup. Two records reporting the same day always come back in the same order and neither\nobservation is discarded.\n\nThe staleness floor that applies to the listing does NOT apply here, in either\ndirection: old points are the substance of a price history, and the sailing is\nresolved without the floor so a timeline stays retrievable for a cruise that has\nsince dropped out of the served feed. Your plan's `X-Data-As-Of` boundary still caps\nhow recent the points may be.","operationId":"getCruiseHistory","parameters":[{"name":"cruise_id","in":"path","required":true,"schema":{"type":"string","title":"Cruise Id"}},{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PriceHistory"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/changes":{"get":{"tags":["Cruises"],"summary":"List recent price changes","description":"Cruises whose price moved between consecutive daily snapshots in the window.\n\nA move is detected WITHIN one record's own series of observations and never across\ntwo. Interleaving two records of one sailing by date would manufacture price changes\nout of the gap between them: on production, 222 of the 1,013 record pairs that\ndescribe one sailing quote lead-in fares more than 10% apart and 165 quote different\ncurrencies entirely, so a cross-record LAG would report a 111% \"drop\" that nobody\never priced. `series` says which record a move was observed in; `id` is the sailing,\nso it always resolves under `/v1/cruises/{id}` to the row the listing serves.\n\nLike `/v1/cruises/{id}/history`, this reads the sailing off\n`cruise_price_history.sailing_key` and joins straight to the row that represents it.\nThe describing fields (`cruise_line`, `ship_name`, `departure_date`) therefore come\nfrom that served row — they are properties of the SAILING and the served row is the\none the rest of the API publishes them from. `price_currency` is the exception and\ncomes from the observation: the move is `old_price` -> `new_price` as one record\nquoted it, and stamping the served row's currency on another record's numbers would\nmisdenominate them.","operationId":"listPriceChanges","parameters":[{"name":"since","in":"query","required":false,"schema":{"type":"string","format":"date","description":"Only changes on/after this date (default: last 30 days)","title":"Since"},"description":"Only changes on/after this date (default: last 30 days)"},{"name":"cruise_line","in":"query","required":false,"schema":{"type":"string","title":"Cruise Line"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":1000,"minimum":1,"default":100,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePage"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/cruise-lines":{"get":{"tags":["Reference"],"summary":"List cruise lines","description":"Every cruise_line value in the catalogue, classified.\n\nA handful of the upstream values name the company that owns the hulls rather than\nthe brand that sells the sailing (\"Scylla Cruises\" is Tauck and Riviera Travel\ncharters), and three are browse categories rather than companies. Those names are\nleft exactly as stored — customers filter `cruise_line` by exact match, and the\nright brand differs from sailing to sailing inside one of these buckets, so no\nrename could be right for all of it. `kind` says which is which; pass\n`kind=retail` for only the names a traveller could actually book under.\n\nOnly lines that still appear on sailings inside the freshness window are listed —\nthe `X-Data-Seen-Since` response header gives that date, or `any` when no floor is\nconfigured. A line whose every sailing has expired is not offered as a filter value,\nbecause filtering on it would return nothing.","operationId":"listCruiseLines","parameters":[{"name":"kind","in":"query","required":false,"schema":{"type":"string","description":"Optional. Keep only lines of this kind: \"retail\" (a bookable brand), \"owner\" (a vessel owner chartering to other brands) or \"category\" (an aggregator browse category, not a company). Omit for every line, which is the historic behaviour.","title":"Kind"},"description":"Optional. Keep only lines of this kind: \"retail\" (a bookable brand), \"owner\" (a vessel owner chartering to other brands) or \"category\" (an aggregator browse category, not a company). Omit for every line, which is the historic behaviour.","example":"retail"},{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CruiseLineList"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/ships":{"get":{"tags":["Ships"],"summary":"List & filter ships","description":"Vessel metadata (specs, capacity, build). Replaces the old name-only list.","operationId":"listShips","parameters":[{"name":"q","in":"query","required":false,"schema":{"type":"string","description":"Optional. Case-insensitive ship-name search.","title":"Q"},"description":"Optional. Case-insensitive ship-name search.","example":"spirit"},{"name":"operator","in":"query","required":false,"schema":{"type":"string","description":"Optional. Filter by operator (partial match).","title":"Operator"},"description":"Optional. Filter by operator (partial match).","example":"Carnival"},{"name":"flag_state","in":"query","required":false,"schema":{"type":"string","description":"Optional. Filter by flag state (partial match).","title":"Flag State"},"description":"Optional. Filter by flag state (partial match).","example":"Bahamas"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"description":"Max rows to return.","default":50,"title":"Limit"},"description":"Max rows to return."},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Rows to skip.","default":0,"title":"Offset"},"description":"Rows to skip."},{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShipPage"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/ships/{ship_id}":{"get":{"tags":["Ships"],"summary":"Get a ship by IMO","description":"One ship by its public id — an IMO number, or the opaque `shp_…` handle.\n\nAn IMO matches `source_id` directly. An opaque handle cannot be reversed, so it is\nresolved by recomputing the hash; the vessel table is small enough (a few thousand\nrows) that scanning it costs less than carrying a stored column and its migration.","operationId":"getShip","parameters":[{"name":"ship_id","in":"path","required":true,"schema":{"type":"string","title":"Ship Id"}},{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShipOut"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/ports":{"get":{"tags":["Reference"],"summary":"List departure ports","description":"Every embarkation port currently on offer.\n\nOnly ports that still appear on sailings inside the freshness window are listed —\nthe `X-Data-Seen-Since` response header gives that date, or `any` when no floor is\nconfigured. A port that survives only on expired sailings is not offered as a filter\nvalue, because filtering on it would return nothing.","operationId":"listPorts","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValueList"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/stats":{"get":{"tags":["Reference"],"summary":"Catalogue stats","operationId":"getStats","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Stats"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/coverage":{"get":{"tags":["Reference"],"summary":"Field coverage contract","description":"What fraction of served records actually populate each field.\n\nEvery field on a sailing except `id` is optional, which by itself tells a caller\nnothing: `title` is optional and present on 98% of records, `obc_amount` is optional\nand present on 0.3%. This endpoint is the difference between those two, measured\nover the de-duplicated set (`is_primary = 1`) that listings and exports return.\n\nEach field is classified `guaranteed` (present on every row, and structurally\nimpossible for the pipeline to omit — safe to dereference), `usually_present`\n(populated on 85%+ of rows) or `best_effort` (populated only where the underlying\nsource publishes it — an enrichment, never a required input), with the measured\nnumber beside it.\n\nThe figures are recomputed by the daily ingest and read from a precomputed table,\nso this is a cheap call. Nothing here is hand-maintained: the field list comes from\nthe response models and the numbers come from the data, so the contract cannot\ndrift away from what the API returns.\n\nDeliberately unauthenticated, like `/v1/stats`: it publishes no sailing data, and\nthe whole point is that someone can find out what a record contains BEFORE they\nintegrate rather than after.","operationId":"getFieldCoverage","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CoverageReportOut"}}}},"400":{"description":"Bad Request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Not Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"429":{"description":"Too Many Requests","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/account/key":{"post":{"tags":["Account"],"summary":"Get or create the caller's free API key","operationId":"account_key_v1_account_key_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"requestBody":{"content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/OnboardBody"},{"type":"null"}],"title":"Body"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyOut"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/account/key/regenerate":{"post":{"tags":["Account"],"summary":"Revoke and re-issue the caller's API key","description":"Rotate the key: the old one stops working immediately and a new plaintext is\nreturned once. The plan (tier + quota) and Stripe link are preserved.","operationId":"account_key_regenerate_v1_account_key_regenerate_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KeyOut"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/account":{"get":{"tags":["Account"],"summary":"Account overview (plan, key, usage)","operationId":"account_overview_v1_account_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccountOut"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/account/terms":{"get":{"tags":["Account"],"summary":"Has the caller accepted the current Terms?","description":"The acceptance gate's read side.\n\n`accepted` is false both for someone who never agreed and for someone whose stored\nversion no longer matches the published one (see legal.py) — a version bump therefore\nre-prompts everyone with no migration or reset.","operationId":"account_terms_v1_account_terms_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TermsStatus"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/account/terms/accept":{"post":{"tags":["Account"],"summary":"Record acceptance of the current Terms","description":"Record that this customer accepted the current Terms + Privacy Policy.\n\nStores the versions, a UTC timestamp and the originating IP — this is evidence of\ncontract formation, so it is appended, never overwritten. Called by the sign-up page\nas soon as the account exists, and by the blocking gate on sign-in/dashboard.","operationId":"account_terms_accept_v1_account_terms_accept_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"requestBody":{"content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/TermsAcceptBody"},{"type":"null"}],"title":"Body"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TermsStatus"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/account/checkout":{"post":{"tags":["Account"],"summary":"Start a Stripe Checkout to upgrade","operationId":"account_checkout_v1_account_checkout_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckoutBody"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UrlOut"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/account/wallet":{"get":{"tags":["Account"],"summary":"Pay-as-you-go balance and history","description":"The prepaid balance, what it still buys, and the money movements behind it.\n\nRead-only and never gated: a customer must always be able to see what they have\npaid for, including one who has not accepted a newer version of the Terms.","operationId":"account_wallet_v1_account_wallet_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WalletDetail"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/account/wallet/topup":{"post":{"tags":["Account"],"summary":"Buy pay-as-you-go credit","description":"Start a one-off Stripe Checkout that adds `amount_usd` to the caller's wallet.\n\nThe wallet is credited by the WEBHOOK, on `checkout.session.completed` — never here.\nThis endpoint only hands back a payment URL; treating a returned URL as a completed\npayment is how you credit an abandoned checkout.","operationId":"account_wallet_topup_v1_account_wallet_topup_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TopupBody"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UrlOut"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/account/portal":{"post":{"tags":["Account"],"summary":"Open the Stripe Billing Portal","operationId":"account_portal_v1_account_portal_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"type":"string","title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UrlOut"}}}},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Bad Request"},"401":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Unauthorized"},"404":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Not Found"},"429":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}},"description":"Too Many Requests"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AccountOut":{"properties":{"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"company":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Company"},"use_case":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Use Case"},"plan":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Plan"},"key_prefix":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key Prefix"},"has_key":{"type":"boolean","title":"Has Key"},"usage":{"$ref":"#/components/schemas/Usage"},"results":{"anyOf":[{"$ref":"#/components/schemas/ResultAllowance"},{"type":"null"}]},"wallet":{"anyOf":[{"$ref":"#/components/schemas/WalletOut"},{"type":"null"}]}},"type":"object","required":["has_key","usage"],"title":"AccountOut"},"Allowance":{"properties":{"kind":{"type":"string","title":"Kind","default":"grant"},"limit":{"type":"integer","title":"Limit"},"used":{"type":"integer","title":"Used"},"remaining":{"type":"integer","title":"Remaining"},"exhausted":{"type":"boolean","title":"Exhausted"},"period":{"type":"string","title":"Period"},"resets_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resets At"},"upgrade_url":{"type":"string","title":"Upgrade Url"},"balance_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Balance Usd"},"usd_per_1000_results":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Usd Per 1000 Results"},"truncated":{"type":"boolean","title":"Truncated","default":false}},"type":"object","required":["limit","used","remaining","exhausted","period","upgrade_url"],"title":"Allowance","description":"What is left of a metered plan's result allowance.\n\nPresent on every metered response, not only when it runs out — a client that can\nwatch `remaining` fall can act before anything breaks. Absent entirely on\nunlimited plans, so its presence is itself the signal that a cap applies.\n\nTwo meters share this shape. `kind=\"grant\"` is a monthly allowance — Free (2,000\nresults) and Snapshot (40,000) — with a fixed `limit` that resets at `resets_at`.\n`kind=\"wallet\"` is pay-as-you-go: `remaining` is what the prepaid balance still buys\nat `usd_per_1000_results`, `limit` is that plus what has already been served this\nmonth (the ceiling as it stands now, which a top-up raises), and `resets_at` is null\nbecause a balance does not reset. One client handles either — `remaining` and\n`exhausted` mean the same thing in both."},"ChangePage":{"properties":{"items":{"items":{"$ref":"#/components/schemas/PriceChange"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"},"allowance":{"anyOf":[{"$ref":"#/components/schemas/Allowance"},{"type":"null"}]}},"type":"object","required":["items","total","limit","offset"],"title":"ChangePage"},"CheckoutBody":{"properties":{"tier":{"type":"string","title":"Tier"},"interval":{"type":"string","title":"Interval","default":"month"}},"type":"object","required":["tier"],"title":"CheckoutBody"},"CoverageReportOut":{"properties":{"computed_at":{"type":"string","format":"date-time","title":"Computed At","description":"When these figures were last measured (UTC). Refreshed daily."},"served_rows":{"type":"integer","title":"Served Rows","description":"Sailings the figures were measured over — the de-duplicated set (`is_primary = 1`) that listings and exports return."},"tiers":{"additionalProperties":{"type":"string"},"type":"object","title":"Tiers","description":"What each tier name commits us to, in prose."},"scopes":{"additionalProperties":{"$ref":"#/components/schemas/CoverageScope"},"type":"object","title":"Scopes","description":"Coverage by object: `cruise`, `itinerary_stop`, `fare`."}},"type":"object","required":["computed_at","served_rows","tiers","scopes"],"title":"CoverageReportOut","description":"The measured field-coverage contract for the served catalogue.\n\nEvery number is computed from the data by the daily ingest — nothing here is\nhand-maintained, so it cannot drift away from what the API actually returns."},"CoverageScope":{"properties":{"denominator":{"type":"string","title":"Denominator","description":"What `total` counts for every field in this scope.","examples":["served sailings (is_primary = 1)"]},"fields":{"items":{"$ref":"#/components/schemas/FieldCoverageOut"},"type":"array","title":"Fields"}},"type":"object","required":["denominator","fields"],"title":"CoverageScope"},"CruiseDetail":{"properties":{"id":{"type":"string","title":"Id","description":"Always present. Opaque, stable sailing identifier."},"cruise_line":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cruise Line","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"ship_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ship Name","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"departure_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Departure Date","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"return_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Return Date","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"duration_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Duration Days","description":"Usually present (85%+ of records). Calendar days the sailing occupies: embarkation day plus one per night, i.e. `nights + 1`. NOT the same as `return_date - departure_date`, and not a redundant copy of `nights`: on land+sea packages (cruisetours, Galapagos + Quito combos) `nights` counts the whole product while the dates cover only the ship segment, and on date-line crossings the calendar span and the nights slept genuinely differ. Use this for the product's length and the dates for the calendar. See GET /v1/coverage for the measured share."},"nights":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Nights","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"round_trip":{"type":"boolean","title":"Round Trip","description":"Always present. Never null. True when the ship ends where it began. Taken from the source where it publishes one, otherwise derived from the canonicalized embark and disembark ports naming the same place; false therefore also covers 'the source did not say and the ports do not agree'.","default":false},"embark_port":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Embark Port","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"disembark_port":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Disembark Port","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"region":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Region","description":"Best effort - populated only where the source publishes it, and null on most records. The destination taxonomy node the sailing is sold under - a sea, coast, river or country (`Mediterranean`, `Alaska`, `Danube`). Null wherever the source publishes no region OR publishes something that is not one: a route (`from Amsterdam to Basel`), a sailing title, a product category (`Ocean Cruises`) or a ship name are all stored as null rather than served as a region, so that filtering on this field means what it says. See GET /v1/coverage for the measured share."},"ship_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ship Code","description":"Best effort - populated only where the source publishes it, and null on most records. See GET /v1/coverage for the measured share."},"destination_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Destination Code","description":"Best effort - populated only where the source publishes it, and null on most records. See GET /v1/coverage for the measured share."},"sea_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sea Days","description":"Best effort - populated only where the source publishes it, and null on most records. Derived from `itinerary`, so null wherever the sailing carries no day-by-day list. See GET /v1/coverage for the measured share."},"port_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Port Count","description":"Best effort - populated only where the source publishes it, and null on most records. Derived from `itinerary`, so null wherever the sailing carries no day-by-day list. See GET /v1/coverage for the measured share."},"price_amount":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Amount","description":"Usually present (85%+ of records). The lead-in (lowest) fare. See GET /v1/coverage for the measured share."},"price_currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Price Currency","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"price_per_night":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Per Night","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"taxes_and_fees":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Taxes And Fees","description":"Best effort - populated only where the source publishes it, and null on most records. Null does NOT mean zero - it means the source did not quote taxes separately, and they may be inside `price_amount`. See GET /v1/coverage for the measured share."},"taxes_currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Taxes Currency","description":"Best effort - populated only where the source publishes it, and null on most records. See GET /v1/coverage for the measured share."},"obc_amount":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Obc Amount","description":"Best effort - populated only where the source publishes it, and null on most records. Onboard credit, quoted by very few sources. Null is the overwhelming default and is not evidence that none is offered. See GET /v1/coverage for the measured share."},"sold_out":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Sold Out","description":"Best effort - populated only where the source publishes it, and null on most records. Three-valued: true, false, or null for 'this source does not report availability'. Do not read null as false. See GET /v1/coverage for the measured share."},"fares":{"items":{"$ref":"#/components/schemas/Fare"},"type":"array","title":"Fares","description":"Best effort - populated only where the source publishes it, and null on most records. Always present as a list, but EMPTY on most records - only some sources quote a per-cabin breakdown. An empty list is not 'no cabins available'. See GET /v1/coverage for the measured share.","default":[]},"itinerary":{"items":{"$ref":"#/components/schemas/ItineraryStop"},"type":"array","title":"Itinerary","description":"Usually present (85%+ of records). Always present as a list; empty where the source publishes no day-by-day breakdown. See GET /v1/coverage for the measured share.","default":[]},"booking_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Booking Url","description":"Best effort - populated only where the source publishes it, and null on most records. A deep link into the line's booking flow, which most sources do not expose. `detail_url` is the more widely populated link. See GET /v1/coverage for the measured share."},"detail_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Detail Url","description":"Best effort - populated only where the source publishes it, and null on most records. The product page for the sailing on the line's own site. See GET /v1/coverage for the measured share."},"scraped_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Scraped At","description":"Always present. When this record last CHANGED. A sailing whose price and itinerary are stable keeps an old `scraped_at` however often it is re-checked, so this is not a freshness signal on its own — pair it with `last_seen_at`."},"last_seen_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Seen At","description":"Always present. When this record was last OBSERVED at the source, whether or not anything about it changed. This is the freshness signal: a row seen this morning is current even if `scraped_at` is weeks old, and a row not seen for a month is stale even if it once changed often."},"ship":{"anyOf":[{"$ref":"#/components/schemas/ShipOut"},{"type":"null"}]}},"type":"object","required":["id"],"title":"CruiseDetail","description":"A single cruise enriched with its matched ship (when known)."},"CruiseLineInfo":{"properties":{"name":{"type":"string","title":"Name"},"kind":{"type":"string","title":"Kind","description":"\"retail\" (a bookable brand — the default), \"owner\" (a vessel owner whose sailings retail under other brands) or \"category\" (a browse category, not a company)","examples":["retail"]},"retail_brands":{"items":{"type":"string"},"type":"array","title":"Retail Brands","description":"For owner lines: brands this inventory is known to sell under. Observed and non-exhaustive; empty for retail and category lines."}},"type":"object","required":["name","kind"],"title":"CruiseLineInfo","description":"One cruise-line facet value, plus what that value actually denotes.\n\nSome aggregator line names are not brands anyone can book: a few name the company\nthat owns the hulls and charters them out, and a few are the aggregator's browse\ncategories. We publish that rather than rewriting the name, because the correct\nbrand varies per sailing and `cruise_line` is filtered on exact match."},"CruiseLineList":{"properties":{"items":{"items":{"type":"string"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"},"lines":{"items":{"$ref":"#/components/schemas/CruiseLineInfo"},"type":"array","title":"Lines"}},"type":"object","required":["items","total","lines"],"title":"CruiseLineList","description":"The cruise-line facet. `items` is unchanged; `lines` is the classified view.\n\nAdditive on purpose: `items`/`total` keep the exact shape and ordering existing\ncallers parse, so nothing that reads this endpoint today has to change."},"CruiseOut":{"properties":{"id":{"type":"string","title":"Id","description":"Always present. Opaque, stable sailing identifier."},"cruise_line":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cruise Line","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"ship_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ship Name","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"departure_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Departure Date","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"return_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Return Date","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"duration_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Duration Days","description":"Usually present (85%+ of records). Calendar days the sailing occupies: embarkation day plus one per night, i.e. `nights + 1`. NOT the same as `return_date - departure_date`, and not a redundant copy of `nights`: on land+sea packages (cruisetours, Galapagos + Quito combos) `nights` counts the whole product while the dates cover only the ship segment, and on date-line crossings the calendar span and the nights slept genuinely differ. Use this for the product's length and the dates for the calendar. See GET /v1/coverage for the measured share."},"nights":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Nights","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"round_trip":{"type":"boolean","title":"Round Trip","description":"Always present. Never null. True when the ship ends where it began. Taken from the source where it publishes one, otherwise derived from the canonicalized embark and disembark ports naming the same place; false therefore also covers 'the source did not say and the ports do not agree'.","default":false},"embark_port":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Embark Port","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"disembark_port":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Disembark Port","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"region":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Region","description":"Best effort - populated only where the source publishes it, and null on most records. The destination taxonomy node the sailing is sold under - a sea, coast, river or country (`Mediterranean`, `Alaska`, `Danube`). Null wherever the source publishes no region OR publishes something that is not one: a route (`from Amsterdam to Basel`), a sailing title, a product category (`Ocean Cruises`) or a ship name are all stored as null rather than served as a region, so that filtering on this field means what it says. See GET /v1/coverage for the measured share."},"ship_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ship Code","description":"Best effort - populated only where the source publishes it, and null on most records. See GET /v1/coverage for the measured share."},"destination_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Destination Code","description":"Best effort - populated only where the source publishes it, and null on most records. See GET /v1/coverage for the measured share."},"sea_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Sea Days","description":"Best effort - populated only where the source publishes it, and null on most records. Derived from `itinerary`, so null wherever the sailing carries no day-by-day list. See GET /v1/coverage for the measured share."},"port_count":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Port Count","description":"Best effort - populated only where the source publishes it, and null on most records. Derived from `itinerary`, so null wherever the sailing carries no day-by-day list. See GET /v1/coverage for the measured share."},"price_amount":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Amount","description":"Usually present (85%+ of records). The lead-in (lowest) fare. See GET /v1/coverage for the measured share."},"price_currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Price Currency","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"price_per_night":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Per Night","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"taxes_and_fees":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Taxes And Fees","description":"Best effort - populated only where the source publishes it, and null on most records. Null does NOT mean zero - it means the source did not quote taxes separately, and they may be inside `price_amount`. See GET /v1/coverage for the measured share."},"taxes_currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Taxes Currency","description":"Best effort - populated only where the source publishes it, and null on most records. See GET /v1/coverage for the measured share."},"obc_amount":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Obc Amount","description":"Best effort - populated only where the source publishes it, and null on most records. Onboard credit, quoted by very few sources. Null is the overwhelming default and is not evidence that none is offered. See GET /v1/coverage for the measured share."},"sold_out":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Sold Out","description":"Best effort - populated only where the source publishes it, and null on most records. Three-valued: true, false, or null for 'this source does not report availability'. Do not read null as false. See GET /v1/coverage for the measured share."},"fares":{"items":{"$ref":"#/components/schemas/Fare"},"type":"array","title":"Fares","description":"Best effort - populated only where the source publishes it, and null on most records. Always present as a list, but EMPTY on most records - only some sources quote a per-cabin breakdown. An empty list is not 'no cabins available'. See GET /v1/coverage for the measured share.","default":[]},"itinerary":{"items":{"$ref":"#/components/schemas/ItineraryStop"},"type":"array","title":"Itinerary","description":"Usually present (85%+ of records). Always present as a list; empty where the source publishes no day-by-day breakdown. See GET /v1/coverage for the measured share.","default":[]},"booking_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Booking Url","description":"Best effort - populated only where the source publishes it, and null on most records. A deep link into the line's booking flow, which most sources do not expose. `detail_url` is the more widely populated link. See GET /v1/coverage for the measured share."},"detail_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Detail Url","description":"Best effort - populated only where the source publishes it, and null on most records. The product page for the sailing on the line's own site. See GET /v1/coverage for the measured share."},"scraped_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Scraped At","description":"Always present. When this record last CHANGED. A sailing whose price and itinerary are stable keeps an old `scraped_at` however often it is re-checked, so this is not a freshness signal on its own — pair it with `last_seen_at`."},"last_seen_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Seen At","description":"Always present. When this record was last OBSERVED at the source, whether or not anything about it changed. This is the freshness signal: a row seen this morning is current even if `scraped_at` is weeks old, and a row not seen for a month is stale even if it once changed often."}},"type":"object","required":["id"],"title":"CruiseOut","description":"One sailing.\n\nEvery field except `id` is nullable, and that alone tells a caller very little:\n`title` is nullable and present on 98% of records, `obc_amount` is nullable and\npresent on 0.3%. `GET /v1/coverage` publishes the measured share for every field\nbelow, classified `guaranteed` / `usually_present` / `best_effort`, recomputed daily\nfrom the data. Field descriptions here name the tier; the endpoint has the numbers."},"CruisePage":{"properties":{"items":{"items":{"$ref":"#/components/schemas/CruiseOut"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"},"allowance":{"anyOf":[{"$ref":"#/components/schemas/Allowance"},{"type":"null"}]}},"type":"object","required":["items","total","limit","offset"],"title":"CruisePage"},"ErrorBody":{"properties":{"code":{"type":"string","title":"Code"},"message":{"type":"string","title":"Message"}},"type":"object","required":["code","message"],"title":"ErrorBody"},"ErrorResponse":{"properties":{"error":{"$ref":"#/components/schemas/ErrorBody"}},"type":"object","required":["error"],"title":"ErrorResponse"},"Fare":{"properties":{"cabin_class":{"type":"string","title":"Cabin Class"},"cabin_category":{"type":"string","title":"Cabin Category","default":"other"},"cabin_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cabin Code","description":"Best effort - populated only where the source publishes it, and null on most records. See GET /v1/coverage for the measured share."},"price_amount":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Amount","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"price_currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Price Currency","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"price_per_night":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Per Night","description":"Usually present (85%+ of records). See GET /v1/coverage for the measured share."},"available":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Available","description":"Best effort - three-valued: true, false, or null for 'this source does not report availability'. Do not read null as sold out. See GET /v1/coverage for the measured share."}},"type":"object","required":["cabin_class"],"title":"Fare","description":"One cabin-class fare for a sailing (the full per-cabin price breakdown)."},"FieldCoverageOut":{"properties":{"scope":{"type":"string","title":"Scope","description":"Which object the field belongs to: \"cruise\" (a sailing), \"itinerary_stop\" (an entry in `itinerary[]`) or \"fare\" (an entry in `fares[]`).","examples":["cruise"]},"field":{"type":"string","title":"Field","description":"The field name as the API serves it.","examples":["booking_url"]},"tier":{"type":"string","title":"Tier","description":"\"guaranteed\", \"usually_present\" or \"best_effort\" — see `tiers` on the enclosing response for what each one commits us to.","examples":["best_effort"]},"coverage":{"type":"number","title":"Coverage","description":"Share of this scope's rows that populate the field, 0.0–1.0. Measured, not estimated.","examples":[0.1498]},"populated":{"type":"integer","title":"Populated","description":"Rows in which the field is non-null."},"total":{"type":"integer","title":"Total","description":"The scope's denominator (see `denominator`)."},"guaranteed_because":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Guaranteed Because","description":"Present only on guaranteed fields: the reason the pipeline cannot emit a row without this value."},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Note","description":"Set on list-valued fields, which are never null — for those, `coverage` is the share of rows whose list is non-empty."}},"type":"object","required":["scope","field","tier","coverage","populated","total"],"title":"FieldCoverageOut","description":"How often one field is actually populated, and what that commits us to."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"ItineraryStop":{"properties":{"seq":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Seq","description":"Always present. 1-based position in the stop list. Use this, not `day_number`, to order the stops."},"day_number":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Day Number","description":"Best effort - the 1-based sailing day (embark day = 1), null on about half of all stops because many sources publish an ordered list without dating it. See GET /v1/coverage for the measured share."},"date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Date","description":"Best effort - the calendar date of this stop, populated on the same stops as `day_number`. See GET /v1/coverage for the measured share."},"port":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Port","description":"Usually present. Null on a day at sea and on a package day that is not on the ship (see `segment`). See GET /v1/coverage for the measured share."},"port_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Port Code","description":"Best effort - null on most stops. Not a stable identifier scheme: sources use different code vocabularies. See GET /v1/coverage for the measured share."},"country":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Country","description":"Best effort - populated only where the source publishes it, and null on most records. See GET /v1/coverage for the measured share."},"region":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Region","description":"Best effort - populated only where the source publishes it, and null on most records. See GET /v1/coverage for the measured share."},"date_raw":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Date Raw","description":"Best effort - the source's own display string for the day. Descriptive only; never parse it. See GET /v1/coverage for the measured share."},"arrive":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Arrive"},"depart":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Depart"},"is_embark":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Embark"},"is_disembark":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Disembark"},"is_sea_day":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Sea Day"},"overnight":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Overnight"},"tender":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Tender"},"segment":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Segment"},"segment_note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Segment Note"}},"additionalProperties":true,"type":"object","title":"ItineraryStop","description":"One day of a sailing's itinerary.\n\nA stop is one of three things and the fields say which: a port call (`port` is\nset), a day at sea (`is_sea_day`), or a day of the package that is not on the ship\nat all (`segment`).\n\nTyped loosely on purpose. Every field is optional and unknown keys are passed\nthrough, because this shape is served straight out of a JSON column holding\n174,055 sailings written by 47 adapters over two years: a required field or a\nrejected key here would turn one odd historic row into a failed response."},"KeyOut":{"properties":{"key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Key"},"prefix":{"type":"string","title":"Prefix"},"tier":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tier"},"monthly_quota":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Monthly Quota"},"created":{"type":"boolean","title":"Created"}},"type":"object","required":["prefix","created"],"title":"KeyOut"},"OnboardBody":{"properties":{"company":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Company"},"use_case":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Use Case"},"utm_source":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Utm Source"},"utm_campaign":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Utm Campaign"}},"type":"object","title":"OnboardBody"},"PriceChange":{"properties":{"id":{"type":"string","title":"Id"},"cruise_line":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cruise Line"},"ship_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ship Name"},"departure_date":{"anyOf":[{"type":"string","format":"date"},{"type":"null"}],"title":"Departure Date"},"old_price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Old Price"},"new_price":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"New Price"},"price_currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Price Currency"},"old_date":{"type":"string","format":"date","title":"Old Date"},"new_date":{"type":"string","format":"date","title":"New Date"},"series":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Series"}},"type":"object","required":["id","old_date","new_date"],"title":"PriceChange","description":"A cruise whose price moved between two snapshots in the queried window.\n\n`id` is the SAILING's id — the one the listing and `/v1/cruises/{id}` serve — not\nnecessarily the id of the record that observed the move. `series` is the record, and\nit is what `old_price`/`new_price` are denominated in; when it differs from `id`, the\nmove was observed by a record other than the one supplying the published price."},"PriceHistory":{"properties":{"id":{"type":"string","title":"Id"},"points":{"items":{"$ref":"#/components/schemas/PricePoint"},"type":"array","title":"Points"}},"type":"object","required":["id","points"],"title":"PriceHistory"},"PricePoint":{"properties":{"snapshot_date":{"type":"string","format":"date","title":"Snapshot Date"},"price_amount":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Amount"},"price_currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Price Currency"},"price_per_night":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Per Night"},"nights":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Nights"},"captured_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Captured At"},"series":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Series"}},"type":"object","required":["snapshot_date"],"title":"PricePoint","description":"One day's recorded price for a cruise (only days the value changed)."},"ResultAllowance":{"properties":{"kind":{"type":"string","title":"Kind","default":"grant"},"limit":{"type":"integer","title":"Limit"},"used":{"type":"integer","title":"Used"},"remaining":{"type":"integer","title":"Remaining"},"exhausted":{"type":"boolean","title":"Exhausted"},"resets_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Resets At"},"upgrade_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Upgrade Url"},"balance_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Balance Usd"},"usd_per_1000_results":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Usd Per 1000 Results"}},"type":"object","required":["limit","used","remaining","exhausted"],"title":"ResultAllowance","description":"The RESULT allowance — the cap customers actually hit.\n\nDistinct from `Usage`, which counts requests. A free key can spend its whole\nresult allowance in a handful of calls, so the request meter alone tells a\ncustomer nothing about why their integration stopped returning rows.\n\n`kind` is \"grant\" for a monthly allowance (Free 2,000, Snapshot 40,000) and\n\"wallet\" for pay-as-you-go, where `remaining` is what the balance buys and\n`resets_at` is null. Same shape, two meters — see api/schemas.py::Allowance, which\nthis mirrors deliberately so the dashboard and the API can never disagree about the\nsame key.\n\n`balance_usd` / `usd_per_1000_results` were missing from this mirror while\n`Allowance` carried them, and \"mirrors deliberately\" turned out to be a comment\nrather than a fact. The dashboard read the absent balance as `0`, decided a healthy\nwallet was nearly empty, and showed \"$0.00 left — about 10,000 results\" over a $25\nbalance. If you add a field to `Allowance`, add it here."},"ShipOut":{"properties":{"id":{"type":"string","title":"Id"},"imo_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Imo Number"},"mmsi_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Mmsi Number"},"ship_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ship Name"},"operator":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Operator"},"owner":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner"},"year_built":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Year Built"},"last_refurbishment":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Last Refurbishment"},"passengers":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Passengers"},"passengers_max":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Passengers Max"},"crew":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Crew"},"passengers_to_space_ratio":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Passengers To Space Ratio"},"gross_tonnage":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Gross Tonnage"},"length_m":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Length M"},"beam_m":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Beam M"},"speed_knots":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Speed Knots"},"decks":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Decks"},"decks_with_cabins":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Decks With Cabins"},"cabins":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Cabins"},"ship_class":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ship Class"},"builder":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Builder"},"building_cost":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Building Cost"},"flag_state":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Flag State"},"engines_power":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Engines Power"},"propulsion_power":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Propulsion Power"},"sister_ships":{"items":{"type":"string"},"type":"array","title":"Sister Ships","default":[]},"former_names":{"items":{"type":"string"},"type":"array","title":"Former Names","default":[]},"christened_by":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Christened By"},"ship_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ship Url"},"scraped_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Scraped At"}},"type":"object","required":["id"],"title":"ShipOut","description":"Vessel metadata (specs, capacity, build) for one ship.\n\nLike cruises, the data provider is abstracted away: no `source` is exposed.\n\n`id` is the vessel's public identifier and the key for GET /v1/ships/{ship_id}:\nthe IMO number where the vessel has one, otherwise an opaque `shp_…` handle. It\nused to be the provider's own key, which for vessels without an IMO was a URL\nslug that named the provider and linked back to them."},"ShipPage":{"properties":{"items":{"items":{"$ref":"#/components/schemas/ShipOut"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"}},"type":"object","required":["items","total","limit","offset"],"title":"ShipPage"},"Stats":{"properties":{"total":{"type":"integer","title":"Total"},"unique_total":{"type":"integer","title":"Unique Total"},"last_updated":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Updated"}},"type":"object","required":["total","unique_total"],"title":"Stats"},"TermsAcceptBody":{"properties":{"terms_version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Terms Version"},"privacy_version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Privacy Version"}},"type":"object","title":"TermsAcceptBody"},"TermsStatus":{"properties":{"accepted":{"type":"boolean","title":"Accepted"},"terms_version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Terms Version"},"privacy_version":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Privacy Version"},"accepted_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Accepted At"},"current_terms_version":{"type":"string","title":"Current Terms Version"},"current_privacy_version":{"type":"string","title":"Current Privacy Version"},"terms_url":{"type":"string","title":"Terms Url"},"privacy_url":{"type":"string","title":"Privacy Url"}},"type":"object","required":["accepted","current_terms_version","current_privacy_version","terms_url","privacy_url"],"title":"TermsStatus","description":"Whether the caller has accepted the *currently published* legal documents."},"TopupBody":{"properties":{"amount_usd":{"type":"number","title":"Amount Usd"}},"type":"object","required":["amount_usd"],"title":"TopupBody"},"UrlOut":{"properties":{"url":{"type":"string","title":"Url"}},"type":"object","required":["url"],"title":"UrlOut"},"Usage":{"properties":{"used":{"type":"integer","title":"Used"},"quota":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Quota"},"period":{"type":"string","title":"Period"}},"type":"object","required":["used","period"],"title":"Usage"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"ValueList":{"properties":{"items":{"items":{"type":"string"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["items","total"],"title":"ValueList","description":"Envelope for facet endpoints (cruise-lines, ports)."},"WalletDetail":{"properties":{"balance_usd":{"type":"number","title":"Balance Usd"},"results_remaining":{"type":"integer","title":"Results Remaining"},"usd_per_1000_results":{"type":"number","title":"Usd Per 1000 Results"},"topped_up_usd":{"type":"number","title":"Topped Up Usd"},"spent_usd":{"type":"number","title":"Spent Usd"},"low":{"type":"boolean","title":"Low"},"empty":{"type":"boolean","title":"Empty"},"funded":{"type":"boolean","title":"Funded"},"min_topup_usd":{"type":"integer","title":"Min Topup Usd"},"presets_usd":{"items":{"type":"integer"},"type":"array","title":"Presets Usd","default":[]},"transactions":{"items":{"$ref":"#/components/schemas/WalletTransactionOut"},"type":"array","title":"Transactions","default":[]}},"type":"object","required":["balance_usd","results_remaining","usd_per_1000_results","topped_up_usd","spent_usd","low","empty","funded","min_topup_usd"],"title":"WalletDetail"},"WalletOut":{"properties":{"balance_usd":{"type":"number","title":"Balance Usd"},"results_remaining":{"type":"integer","title":"Results Remaining"},"usd_per_1000_results":{"type":"number","title":"Usd Per 1000 Results"},"topped_up_usd":{"type":"number","title":"Topped Up Usd"},"spent_usd":{"type":"number","title":"Spent Usd"},"low":{"type":"boolean","title":"Low"},"empty":{"type":"boolean","title":"Empty"},"funded":{"type":"boolean","title":"Funded"},"min_topup_usd":{"type":"integer","title":"Min Topup Usd"},"presets_usd":{"items":{"type":"integer"},"type":"array","title":"Presets Usd","default":[]}},"type":"object","required":["balance_usd","results_remaining","usd_per_1000_results","topped_up_usd","spent_usd","low","empty","funded","min_topup_usd"],"title":"WalletOut","description":"The pay-as-you-go balance.\n\nPresent on the overview whenever the account has ever topped up (`funded`), so the\ndashboard can show a balance for a customer who has since moved onto a\nsubscription — the credit doesn't evaporate, and they get it back if they cancel."},"WalletTransactionOut":{"properties":{"kind":{"type":"string","title":"Kind"},"amount_usd":{"type":"number","title":"Amount Usd"},"balance_after_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Balance After Usd"},"note":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Note"},"created_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Created At"}},"type":"object","required":["kind","amount_usd"],"title":"WalletTransactionOut","description":"One money movement: a top-up, a refund, or an ops adjustment.\n\nPer-result charges are NOT itemised here — see models.wallet_transactions_table.\n`spent_usd` on the wallet is the total, and the usage meter is the volume."}}}}