openapi: 3.0.3
info:
  title: Lydira Public API
  description: |
    Skeleton JSON API. Authenticate with an API key from **Settings → API Keys** (`sek_` prefix).
    Keys are scoped to an account.

    **Base URL:** `https://<your-cell>/api/v1` (no locale or account slug prefix).
    The host is per account, not per product: see `servers` below, and read it off
    Settings → API keys rather than guessing.

    **Lists** always put the collection under `data`. A list over records also carries
    `page`: `{ "data": [...], "page": { "next_cursor", "has_more", "limit" } }`. Walk one by
    re-sending the request with `?cursor=<page.next_cursor>` until `has_more` is `false`;
    `?limit=` takes 1-100 and defaults to 25. Cursors are opaque — do not parse or construct
    them. A bounded list has no `page` to carry: a catalogue
    (`GET /api/v1/imports/entities`), a trip's days, the account's users. Some responses put
    context beside the collection — the batch on `GET /api/v1/imports/{id}/rows`, the limits
    and conventions on the catalogue — but the collection itself is always `data`.

    **Rate limits:** every response to a request carrying a resolvable key — including the
    refusals — carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`.
    `Reset` is a UTC epoch second: the instant the current 60-second window rolls and
    `Remaining` returns to `Limit`. The reported budget is the TIGHTEST one standing between
    you and a `429`, which on the ingest `POST`s is their own lower ceiling rather than your
    account's requests-per-minute. A `429` carries the same three plus `Retry-After` (the
    same moment, as a delta in seconds). A request with no usable key never reached a budget
    and carries none of them.

    **Idempotency:** any `POST` / `PUT` / `PATCH` / `DELETE` may carry an
    `Idempotency-Key` header (your own unique string, max 255 chars). The header is
    optional; when you send one, a repeat of the same request returns the first
    call's response verbatim with `Idempotent-Replay: true` instead of writing
    again. Only a `2xx` is remembered — a failed write leaves the key free to
    retry. Reusing a key for a different body or path returns `422`
    (`idempotency_key_reuse`), and a repeat while the first call is still running
    returns `409` (`idempotency_conflict`) with `Retry-After`. Keys are scoped to
    your account and kept for 7 days.

    **Errors** are always JSON: `{ "error": "...", "code": "..." }`. A key that no
    longer works says why rather than answering a flat `unauthorized`:
    `key_revoked` and `key_expired` are `401`, and `ip_not_allowed` is `403` —
    the credential is fine, the address is not, so retrying with a different
    token is the wrong thing to try.

    **Permissions:** the scope decides which endpoints a key reaches; the agency
    may additionally bind the key to a ROLE, which decides what it may do once
    inside them. A role that does not cover an operation answers `403 forbidden`,
    and money / personal-data fields come back as `null` where the role cannot
    see them. Unbound keys act with the account owner's unrestricted permissions.
  version: '1.0.0'
  contact:
    name: Lydira

servers:
  - url: 'https://{host}'
    description: |
      **There is no single API address.** Lydira runs one cell per residency
      region, each with its own database, and an account lives in exactly one
      of them: `tr.lydira.com`, `eu.lydira.com` or `us.lydira.com`. A
      self-hosted or custom-domain install has its own.

      Take the host from the address bar of your Lydira tab. Settings → API
      keys states it next to the key, which is the one place it is certain to
      be right.

      Getting it wrong is quiet rather than loud: a key from another cell does
      not resolve there, so you get `401 unauthorized`, which reads like a bad
      key rather than like the wrong address.
    variables:
      host:
        default: YOUR-CELL.lydira.com
        description: 'Your cell, e.g. `eu.lydira.com`. Locally, use `localhost:3000` with `http`.'

tags:
  - name: Health
    description: Unauthenticated liveness check
  - name: Webhooks
    description: |
      Outbound webhook subscriptions. `record.created` / `record.updated` /
      `record.deleted` fire for every booking an integration deals in and carry an
      `ImportedRecord` body — the same shape
      `GET /api/v1/imports/{entity}/records/{external_id}` returns, including YOUR
      `external_id` when the record came in through an import. `record.updated`
      fires only when a meaningful field moves (status, or a field the read-back
      publishes), not on every recalculation.

      A subscription narrows on two axes. `events` picks which of the three you
      receive; `entities` picks which record types, using the same keys as
      `/api/v1/imports/{entity}` — `tickets`, `hotel_stays`, `transfers`,
      `cruises`, `car_rentals`, `tour_bookings`, `tour_departures`, `charters`,
      `visa_cases`, `events`.

      **An empty or omitted `entities` means all of them**, which is what every
      subscription registered before the filter existed carries, so nothing has to
      be re-registered — and it means a type added later starts arriving at that
      endpoint too. Pick the types explicitly if that matters to you. An entity
      that cannot fire a webhook (`customers`, `operator_invoices`, …) is refused
      with `422` rather than accepted into an endpoint that would then never be
      called.

      Broadcasting is not the same permission as importing. `charters`,
      `visa_cases` and `events` are **read-only** over `/api/v1/imports`: they
      broadcast, they page through `GET /api/v1/imports/{entity}/records`, and a
      `POST` to them answers `422 entity_not_writable`.

      `tour_departures` is inventory rather than a booking, and subscribing to it
      is the only way to hear that a departure was cancelled or sold out: that
      does not move the status of the seats sold on it, so `tour_bookings` alone
      stays silent.

      One name appears on both axes of the same body, so read it carefully: the
      `events` FIELD picks which of `record.created` / `record.updated` /
      `record.deleted` you receive, while `"events"` inside `entities` is the
      event-project vertical —
      `{"events": ["record.created"], "entities": ["events", "charters"]}`.
  - name: Ticket printer
    description: |
      The Air Printer ingest: a desktop agent watches the GDS ticketing-printer
      folder and POSTs each new file here, where it is parsed into a draft ticket
      sale. Requires a key with the `ticket_import` (or `full`) scope — the narrow
      one is the point, since the agent runs on a PC in an agency office.

      Idempotent twice over: the same BYTES answer `200` with `status: "duplicate"`,
      and so does a re-print carrying a ticket number the account already holds
      (a different file, same document). The agent replays its watch folder after
      any restart, so that is the normal case rather than the exotic one.
  - name: Import
    description: |
      Structured JSON ingest for tickets, hotel stays, transfers, cruises, car rentals,
      tour departures and bookings, customers, suppliers, leads and brought-forward
      documents. Requires an API key with the `import` (or `full`) scope.

      **Reading reaches further than writing.** `charters`, `visa_cases` and `events`
      are registry entities that broadcast webhooks and page through
      `GET /api/v1/imports/{entity}/records`, but cannot be pushed: a `POST` to one
      answers `422 entity_not_writable` and names the read endpoint, rather than the
      `404 unknown_entity` an unrecognised key gets. `GET /api/v1/imports/entities`
      lists the writable set only.

      Three rules hold for every writable entity:

      * **`external_id` is required on every row** — your own key in the source system.
        Re-sending never duplicates: people and brought-forward documents UPDATE, bookings SKIP.
      * **Dry-run ≡ commit.** `POST …/validate` runs the identical path with nothing persisted.
      * **A bad row fails alone**, with a per-field reason; the rest of the batch still imports.

      Field lists are not enumerated here because they are registry-driven and evolve —
      `GET /api/v1/imports/entities/{key}` is the authoritative, always-current schema and
      ships a ready-to-POST sample body. A Postman collection generated from the
      same source is downloadable from Settings → API keys, and from this guide.

  - name: Account
    description: |
      Who the key is, and the ids every other call needs. `GET /api/v1/me`
      resolves the account, the companies and branches the key can reach, and
      the plan features that decide which endpoints answer at all; a key runs as
      the account owner unless the agency bound it to a role, in which case that
      role's permission set applies and masked fields come back `null`.

      `GET /api/v1/users` is the seat directory, which is where an
      `assigned_to_id` comes from.
  - name: Customers
    description: |
      The agency's client records, plus the note thread on each one. `name`,
      `email`, `phone` and the tax fields are personal data: a key whose role
      cannot see PII reads them as `null`, and a customer carrying a restricted
      tag has its identity masked as well, so a row is never withheld outright,
      only emptied.

      Notes accept attachments as Active Storage `signed_id`s. Upload the file
      first, then send the ids.
  - name: Leads
    description: |
      Inbound enquiries and the pipeline they move through. `stage` advances via
      `PATCH /api/v1/leads/{id}/transition` rather than a plain update, because
      the move is recorded with its actor and, for a loss, its reason.

      `POST /api/v1/leads/{id}/convert` is the terminal step: it mints a Customer
      and a Trip from the lead in one transaction and answers with all three.
  - name: Tasks
    description: |
      The advisor to-do list. Tasks may hang off a Trip, a Lead, a Customer or
      any other linkable record through `taskable_type` + `taskable_id`;
      `taskable_label` resolves that pair to something readable so a client need
      not fetch the target to render a row.

      Completion is its own verb (`complete` / `reopen`) and stamps who closed it.
  - name: Trips
    description: |
      The itinerary aggregate: a trip holds days, days hold segments. Read-only
      over the API in v1, with one exception: share links can be minted and
      revoked, which is how an integration hands a traveller a public itinerary
      URL.

      A day is addressed by its `day_number` (1, 2, 3 …), not by its id, so a
      deep link stays readable. A segment is addressed by id.

      Money on a segment follows the key's role. `price_amount`, `cost_amount`
      and the commission fields are present only where the role can see them.
  - name: Communications
    description: |
      The unified inbox: email, SMS, WhatsApp and internal notes against a
      customer or a trip. Sending is asynchronous. A `POST` with an outbound
      `direction` on a deliverable channel returns the queued row immediately
      and the transport runs after; poll `status` / `sent_at` / `failed_at`, or
      subscribe to the record webhooks.

      Message bodies are PII. A role without that permission sees
      `content_masked: true` with `subject`, `preview` and both body fields
      `null`.
  - name: Notifications
    description: |
      The in-app bell for the seats on the account, not a delivery channel of
      its own. Every list and every mark-read answers with the live
      `unread_count` alongside, so a client never has to count for itself.

      Trip reminder scheduling is a different thing and lives at
      `GET /api/v1/trips/{trip_id}/notifications`.
  - name: Reports
    description: |
      Read-only export of the same reports the browser runs. Two families share
      one result shape: preset reports off the built-in catalogue
      (`GET /api/v1/reports`) and the agency's own saved reports from the report
      builder (`GET /api/v1/saved_reports`).

      Both are plan-gated per report. `GET /api/v1/reports` lists only what the
      account's plan actually includes, so an empty catalogue is an answer about
      the plan rather than an error. Running a report the plan excludes answers
      `403 plan_gated`.

      Filters travel as `filters[key]=value`. The catalogue publishes each
      preset's accepted filters and columns, so a client can build the query
      without hardcoding them.
  - name: Insights
    description: |
      Two computed dashboards, each a single call with no pagination.
      `GET /api/v1/performance` is post-trip quality: NPS, survey response rate,
      per-advisor breakdown, top suppliers and recent detractors over a window.
      `GET /api/v1/retention` is the forward view: anniversaries coming up,
      trips due a rebooking nudge, recent outreach and the referral codes that
      actually convert.

      Both are computed fresh on every call rather than served from a cache, so
      treat them as a periodic pull, not a per-request lookup.


security:
  - bearerAuth: []

paths:
  /api/v1/health:
    get:
      tags: [Health]
      summary: Liveness check
      description: Does not require authentication.
      security: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [ok, api]
                properties:
                  ok:
                    type: boolean
                    example: true
                  api:
                    type: string
                    example: v1

  /api/v1/webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook subscriptions
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Subscriptions (signing secrets are not listed)
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookSubscriptionListItem'
                  page:
                    $ref: '#/components/schemas/CursorPage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'
    post:
      tags: [Webhooks]
      summary: Create webhook subscription
      description: |
        Returns `signing_secret` once (`whsec_` prefix). Empty or omitted `events`
        defaults to all allowed events, and empty or omitted `entities` to all
        booking types — so a body carrying only `url` subscribes to everything.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookCreateRequest'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookCreateResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                  code:
                    type: string
                    example: validation_error
        '429':
          $ref: '#/components/responses/TooManyRequests'

  /api/v1/webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Delete webhook subscription
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/TooManyRequests'

  /api/v1/ticket_imports:
    get:
      tags: [Ticket printer]
      summary: Recent captures
      description: |
        Newest first. Doubles as the agent's "test connection" probe and its
        "last N imported" panel; walk `page.next_cursor` to read back past the
        first page, which is what a reconnecting agent needs after an outage.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TicketImport' }
                  page:
                    $ref: '#/components/schemas/CursorPage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Ticket printer]
      summary: Submit a printer file
      description: |
        `201` when the file parsed into a new draft sale, `200` when it was
        already captured (same bytes, or a ticket number the account holds) and
        `422` when it could not be parsed — the capture is still recorded, so a
        failure is visible rather than lost.

        The GDS and the format are sniffed from the CONTENT and are authoritative
        over any `provider` / `format` the agent sends.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [content]
              properties:
                content:
                  type: string
                  description: The printer file, base64-encoded.
                filename: { type: string, example: AIR07.05.24.1342.0000.txt }
                encoding: { type: string, description: Source encoding, when it is not UTF-8. }
                provider: { type: string, description: A hint only; the content decides. }
                format: { type: string, description: A hint only; the content decides. }
                agent:
                  type: object
                  description: The desktop agent's own build, for support.
                  properties:
                    version: { type: string }
                    host: { type: string }
                    os: { type: string }
      responses:
        '201':
          description: Parsed into a new draft ticket sale
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/TicketImport'
                  - type: object
                    properties:
                      pnr: { type: string }
                      ticket_numbers:
                        type: array
                        items: { type: string }
        '200':
          description: Already captured — same bytes, or a ticket number we already hold
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/TicketImport'
                  - type: object
                    properties:
                      status: { type: string, enum: [duplicate] }
                      duplicate_ticket_numbers:
                        type: array
                        nullable: true
                        items: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '422':
          description: The file could not be parsed; the capture is recorded anyway
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [parse_failed, empty_content, no_company] }
                  details:
                    type: object
                    properties:
                      ticket_import_id: { type: string, format: uuid }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/workspace:
    get:
      tags: [Import]
      summary: Where a record can be filed
      description: |
        The account's companies and branches, with their ids — the values
        `company_id` and `branch_id` take on an import. The import endpoints have
        always accepted both and nothing said what they could be, so the ids had
        to be handed over out of band and re-sent whenever the agency opened a
        branch.

        `account.time_zone` is the clock a timestamp with no offset is read in.
        Requires the `import` (or `full`) scope.

        Every branch of the account is listed, whichever office the key's own owner
        sits in. `headquarters` marks the branch a push with no `branch_id` is filed
        under, and it is also the id to pass when you want a feed to answer with
        every branch at once.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  account:
                    type: object
                    properties:
                      id: { type: integer, example: 1000001 }
                      name: { type: string }
                      time_zone: { type: string, example: Europe/Istanbul }
                  companies:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        default:
                          type: boolean
                          description: Where a row that names no `company_id` lands.
                        tax_country: { type: string, example: TR }
                        currency:
                          type: string
                          description: What a row with no `currency` of its own is priced in.
                  branches:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        code: { type: string, nullable: true }
                        company_id: { type: string, format: uuid }
                        headquarters: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/imports/entities:
    get:
      tags: [Import]
      summary: List importable entities
      description: |
        The catalogue — each entity's scope, idempotency rule and endpoints. `data`
        carries the entities; `limits` and `conventions` ride alongside as context.
        No `page`: the registry is a fixed set rather than a collection to walk.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ImportEntitySummary' }
                  limits:
                    type: object
                    properties:
                      max_rows_per_request: { type: integer, example: 500 }
                      sync_row_limit: { type: integer, example: 100 }
                  conventions:
                    type: object
                    additionalProperties: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/imports/entities/{key}:
    get:
      tags: [Import]
      summary: Field schema + sample body for one entity
      description: |
        The authoritative, always-current field list — including nested shapes
        (a ticket's `legs` / `passengers`, a booking's `cost_lines` / `sell_lines`) —
        plus `entity.sample`, a body you can POST as-is.
      parameters:
        - name: key
          in: path
          required: true
          schema: { type: string, example: tickets }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  entity: { $ref: '#/components/schemas/ImportEntitySchema' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/imports/{entity}/validate:
    post:
      tags: [Import]
      summary: Dry-run a batch
      description: |
        Runs the identical path the commit runs and persists **nothing**
        (service-backed entities execute the real CreateService inside a rolled-back
        transaction). Always synchronous.
      parameters:
        - $ref: '#/components/parameters/ImportEntity'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ImportRequest' }
      responses:
        '200':
          description: Preview — every row is `will_create`, `will_update`, `skipped` or `invalid`
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImportResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404':
          description: |
            `unknown_entity` — no entity by that key at all. A key that EXISTS but is
            read-only answers `422 entity_not_writable` instead, so a partner is not
            sent hunting for a spelling mistake that isn't there.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, example: unknown_entity }
        '422': { $ref: '#/components/responses/ImportRejected' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/imports/{entity}:
    post:
      tags: [Import]
      summary: Import a batch
      description: |
        Commits the batch. Batches above the sync row limit (or `mode: "async"`) are
        queued and answer `202`; poll `GET /api/v1/imports/{id}`.
      parameters:
        - $ref: '#/components/parameters/ImportEntity'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ImportRequest' }
      responses:
        '201':
          description: At least one record was created
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImportResponse' }
        '200':
          description: Nothing new — every row updated or skipped (a clean idempotent retry)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImportResponse' }
        '202':
          description: Queued; poll the batch for its result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImportResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404':
          description: |
            `unknown_entity` — no entity by that key at all. See the `422` below for
            a key that exists but is read-only.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, example: unknown_entity }
        '422':
          description: |
            `entity_not_writable` — the entity exists, broadcasts and is readable at
            `GET /api/v1/imports/{entity}/records`, but accepts no push (`charters`,
            `visa_cases`, `events`). Otherwise: the request was refused (`no_rows`,
            `too_many_rows`, `no_company`), or every row failed — a batch where
            nothing landed is never reported as success.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ImportResponse' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/imports/{entity}/records:
    get:
      tags: [Import]
      summary: A resumable change feed for one vertical
      description: |
        Every record of the vertical, ordered by `updated_at` **ascending** — a sync
        wants a watermark it can resume from, not a newest-first list. Poll with
        `?updated_since=<the last updated_at you saw>`, walk forward, remember the last
        one. (Descending would silently drop anything that changed mid-walk.)

        Rows are `ImportedRecord`, the same body the single read-back and the webhooks
        carry, so one parser covers all three.

        The feed includes bookings the AGENCY created in-app, not just the ones you
        pushed — that is the point of it. `?mine=true` narrows it to records bearing an
        `ExternalReference` for your `source_system`.

        **Every branch, unless you name one.** An API key is the account's credential
        rather than a seat in one of its offices, so this feed answers with the whole
        account. `?branch_id=` is the only thing that narrows it, and it takes one id
        from `GET /api/v1/workspace`. Naming the branch whose `headquarters` is true
        answers with every branch, because that is what headquarters means here.
      parameters:
        - $ref: '#/components/parameters/ImportEntity'
        - name: branch_id
          in: query
          description: |
            One branch id from `GET /api/v1/workspace`. Omit for the whole account.
            An id that is not a branch of this account is refused with `422
            unknown_branch` rather than absorbed into a full feed.
          schema: { type: string, format: uuid }
        - name: updated_since
          in: query
          description: ISO-8601. Only records changed at or after this instant.
          schema: { type: string, format: date-time }
        - name: status
          in: query
          description: |
            Comma-separated; unknown values are dropped rather than emptying the list.
            Ignored by the two entities that have no `status` column — a visa case
            is filed under `state`, a tour departure under `inventory_state`, and
            neither is filterable here yet. Walk the feed and read `detail`.
          schema: { type: string, example: 'confirmed,in_house' }
        - name: source_system
          in: query
          schema: { type: string, default: api }
          description: Which source system's ids to resolve into `external_id`.
        - name: mine
          in: query
          schema: { type: boolean, default: false }
          description: Only records this source system pushed.
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ImportedRecord' }
                  page:
                    $ref: '#/components/schemas/CursorPage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ImportRejected' }

  /api/v1/imports/{entity}/records/{external_id}:
    get:
      tags: [Import]
      summary: Read a pushed record back by your own id
      description: |
        Resolves the source system's id to the live Lydira record and reports its
        current state — status, whether it is frozen (invoiced or cancelled, i.e.
        your amendments will be refused), totals, and any invoices raised against it.

        This is how a partner learns what the AGENCY did after the push. The other
        half is outbound webhooks: subscribe to `record.updated` and the same payload
        is delivered when the record changes in-app.
      parameters:
        - $ref: '#/components/parameters/ImportEntity'
        - { name: external_id,  in: path,  required: true, schema: { type: string } }
        - { name: source_system, in: query, schema: { type: string, default: api } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  record: { $ref: '#/components/schemas/ImportedRecord' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }
        '404':
          description: No record was imported under that id from that source system
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [unknown_entity, unknown_external_id] }

  /api/v1/imports:
    get:
      tags: [Import]
      summary: List import batches
      description: |
        This account's API import history, newest first. Comma-separated values are
        accepted on both filters; unknown ones are dropped rather than emptying the list.
      parameters:
        - { name: entity, in: query, schema: { type: string, example: 'tickets,hotel_stays' } }
        - { name: status, in: query, schema: { type: string, example: 'completed,partial' } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ImportBatch' }
                  page:
                    $ref: '#/components/schemas/CursorPage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/InsufficientScope' }

  /api/v1/imports/{id}:
    get:
      tags: [Import]
      summary: One import batch
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  import: { $ref: '#/components/schemas/ImportBatch' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/imports/{id}/rows:
    get:
      tags: [Import]
      summary: Per-row outcomes
      description: |
        `?status=invalid,failed` is the fix-list after a partial import. Ordered by
        `row` ascending, so it reads in the order you sent the batch.

        `data` is the rows; `import` is the batch they belong to, carried alongside
        for context. Its `stats` hold the per-status counts, so the page needs no
        total of its own.
      parameters:
        - { name: id,     in: path,  required: true, schema: { type: string, format: uuid } }
        - { name: status, in: query, schema: { type: string, example: 'invalid,failed' } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [import, data, page]
                properties:
                  import: { $ref: '#/components/schemas/ImportBatch' }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ImportRowResult' }
                  page:
                    $ref: '#/components/schemas/CursorPage'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/v1/me:
    get:
      tags: [Account]
      summary: Who this key is
      description: |
        The call to make first. It resolves the account behind the key, the
        companies and branches it can file against, the permissions it acts
        with and the plan features that decide which other endpoints answer.

        One of the few responses with no `data` envelope: the object is the
        payload.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/users:
    get:
      tags: [Account]
      summary: Seat directory
      description: |
        Everyone who can be assigned work on this account,
        ordered by name. Bounded at 50 rows and not cursored: this is a picker
        source, so narrow it with `?q=` rather than paging it.
      parameters:
        - { name: q, in: query, description: 'Case-insensitive match on name or email.', schema: { type: string } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/UserStub' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/customers:
    get:
      tags: [Customers]
      summary: List customers
      description: 'Newest first. Soft-deleted customers are never returned.'
      parameters:
        - { name: q, in: query, description: 'Matches name, email or phone.', schema: { type: string } }
        - { name: company_id, in: query, description: 'From `GET /api/v1/me`.', schema: { type: string, format: uuid } }
        - { name: vip_tier, in: query, schema: { type: string } }
        - { name: lifecycle_stage, in: query, schema: { type: string } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Customer' }
                  page: { $ref: '#/components/schemas/CursorPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Customers]
      summary: Create a customer
      description: |
        `company_id` defaults to the key owner's home company when omitted, so a
        single-company agency can leave it out entirely.

        This endpoint has no `external_id` and no deduplication. To push
        customers from another system and keep your own ids, use
        `POST /api/v1/imports/customers` instead, which upserts.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer]
              properties:
                customer: { $ref: '#/components/schemas/CustomerWrite' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CustomerDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/customers/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Customers]
      summary: Read a customer
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CustomerDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      tags: [Customers]
      summary: Update a customer
      description: |
        Partial: send only the fields you are changing. `PUT` is routed to the
        same action and behaves identically.

        `tags` is all-or-nothing. Omit the key to leave tags untouched; send an
        array to replace the whole set.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer]
              properties:
                customer: { $ref: '#/components/schemas/CustomerWrite' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CustomerDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/customers/{id}/notes:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Customers]
      summary: List notes on a customer
      description: 'Pinned notes first, then newest first. Bounded, so there is no `page`.'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CustomerNote' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Customers]
      summary: Add a note
      description: |
        A note is text. `attachments` is there for a client that already holds
        Active Storage `signed_id`s, which is how the Lydira apps attach a
        photo; an integration normally sends `body` alone. A batch that would
        push the account past its storage allowance is refused before the note
        is written.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [note]
              properties:
                note:
                  type: object
                  required: [body]
                  properties:
                    body: { type: string }
                    pinned: { type: boolean, default: false }
                    attachments:
                      type: array
                      description: 'Active Storage signed ids. Rarely used over the API; see above.'
                      items: { type: string }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CustomerNote' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/customers/{customer_id}/suggestions:
    get:
      tags: [Customers]
      summary: What to pitch next
      description: |
        Ranked ideas for this customer, blended from trips similar to the ones
        they have taken (vector similarity) and the agency's own trip templates
        filtered by their cached preference profile.

        Computed on demand. When the underlying service cannot answer, this
        returns `503 suggestions_failed` rather than an empty list, so a caller
        can tell "nothing to suggest" from "could not look".
      parameters:
        - { name: customer_id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: limit, in: query, schema: { type: integer, default: 5, minimum: 1, maximum: 10 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CustomerSuggestion' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '503':
          description: The suggestion engine could not answer
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, example: suggestions_failed }

  /api/v1/leads:
    get:
      tags: [Leads]
      summary: List leads
      description: 'Most recently touched first (`updated_at` descending).'
      parameters:
        - { name: q, in: query, description: 'Matches name, email or phone.', schema: { type: string } }
        - { name: stage, in: query, schema: { type: string, example: qualified } }
        - { name: source, in: query, schema: { type: string } }
        - { name: assigned_to_id, in: query, schema: { type: string, format: uuid } }
        - name: scope
          in: query
          description: |
            A named slice, applied on top of the other filters. `mine` is
            relative to the key's principal, which for an unbound key is the
            account owner.
          schema: { type: string, enum: [open, closed, stale, mine] }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Lead' }
                  page: { $ref: '#/components/schemas/CursorPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Leads]
      summary: Capture a lead
      description: |
        Defaults fill in what you leave out: `stage` starts at `inbox`,
        `assigned_to_id` and the creator both become the key's principal, and
        `company_id` falls back to that principal's home company.

        A lead pushed from another system should go through
        `POST /api/v1/imports/leads` instead, which carries your `external_id`
        and upserts on it.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lead]
              properties:
                lead: { $ref: '#/components/schemas/LeadWrite' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/LeadDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/leads/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Leads]
      summary: Read a lead
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/LeadDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      tags: [Leads]
      summary: Update a lead
      description: |
        Partial; `PUT` is routed to the same action. `stage` is deliberately not
        writable here. Move it with
        `PATCH /api/v1/leads/{id}/transition`, which records the actor and the
        reason.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lead]
              properties:
                lead: { $ref: '#/components/schemas/LeadWrite' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/LeadDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/leads/{id}/transition:
    patch:
      tags: [Leads]
      summary: Move a lead to another stage
      description: |
        The audited way to change `stage`. The move is stamped with the actor
        and, when the new stage is `lost`, with `reason`.

        An unknown stage answers `422 invalid_stage`. A move the pipeline does
        not allow answers `422 validation_failed`.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [new_stage]
              properties:
                new_stage:
                  type: string
                  example: qualified
                  description: 'A stage key. `GET /api/v1/leads/{id}` reports the current one.'
                reason:
                  type: string
                  description: 'Recorded on the lead. Meaningful when moving to `lost`.'
      responses:
        '200':
          description: Moved
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/LeadDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Unknown or illegal stage
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [invalid_stage, validation_failed] }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/leads/{id}/convert:
    post:
      tags: [Leads]
      summary: Convert a lead into a customer and a trip
      description: |
        One transaction: a Customer is created (or matched), a Trip is opened
        from the lead's destinations and dates, and the lead is linked to both.
        The response carries all three.

        Not idempotent on its own. Converting an already-converted lead answers
        `422 conversion_failed`; send an `Idempotency-Key` if your caller may
        retry.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '201':
          description: Converted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    required: [lead, trip, customer]
                    properties:
                      lead: { $ref: '#/components/schemas/LeadDetail' }
                      trip:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
                          title: { type: string }
                          status: { type: string }
                          start_date: { type: string, format: date, nullable: true }
                      customer: { $ref: '#/components/schemas/Customer' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: The lead could not be converted
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, example: conversion_failed }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/tasks:
    get:
      tags: [Tasks]
      summary: List tasks
      description: |
        Soonest due first. With no `scope` the list is the pending set, which is
        usually what a worklist wants; ask for `completed` explicitly.
      parameters:
        - { name: q, in: query, description: 'Matches subject or body.', schema: { type: string } }
        - name: scope
          in: query
          description: 'Defaults to pending when omitted.'
          schema: { type: string, enum: [mine, overdue, due_today, upcoming, completed] }
        - { name: priority, in: query, schema: { type: string } }
        - { name: assigned_to_id, in: query, schema: { type: string, format: uuid } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Task' }
                  page: { $ref: '#/components/schemas/CursorPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Tasks]
      summary: Create a task
      description: |
        `taskable_type` + `taskable_id` attach the task to a record. Pass them
        together or not at all.

        `reminder_offsets` is a list of minutes before `due_at`. Reminders only
        fire on a task that has both a `due_at` and an assignee.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task]
              properties:
                task: { $ref: '#/components/schemas/TaskWrite' }
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TaskDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/tasks/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
    get:
      tags: [Tasks]
      summary: Read a task
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TaskDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    patch:
      tags: [Tasks]
      summary: Update a task
      description: 'Partial; `PUT` is routed to the same action. Completion has its own endpoints.'
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task]
              properties:
                task: { $ref: '#/components/schemas/TaskWrite' }
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TaskDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/tasks/{id}/complete:
    patch:
      tags: [Tasks]
      summary: Complete a task
      description: 'Stamps `completed_at` and `completed_by`. Completing a completed task is harmless.'
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TaskDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/tasks/{id}/reopen:
    patch:
      tags: [Tasks]
      summary: Reopen a task
      description: 'Clears the completion stamp and puts the task back in the pending set.'
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Reopened
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TaskDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/trips:
    get:
      tags: [Trips]
      summary: List trips
      description: 'Ordered by `start_date` ascending, so the next departure is first.'
      parameters:
        - { name: q, in: query, description: 'Matches title or reference number.', schema: { type: string } }
        - { name: status, in: query, schema: { type: string } }
        - { name: assigned_to_id, in: query, schema: { type: string, format: uuid } }
        - name: scope
          in: query
          schema: { type: string, enum: [active, upcoming, in_progress, needs_attention] }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Trip' }
                  page: { $ref: '#/components/schemas/CursorPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/trips/{id}:
    get:
      tags: [Trips]
      summary: Read a trip
      description: 'Adds travellers, customers, the settings blob and `day_count` to the list shape. The days themselves are a separate call.'
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TripDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/trips/{trip_id}/days:
    get:
      tags: [Trips]
      summary: List the days of a trip
      description: 'In itinerary order. Bounded by the length of the trip, so there is no `page`.'
      parameters:
        - { name: trip_id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TripDay' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/trips/{trip_id}/days/{id}:
    get:
      tags: [Trips]
      summary: Read one day
      description: |
        `id` here is the **`day_number`**, the 1-based position in the
        itinerary, not a uuid. Day three of a trip is `/days/3`.

        The day carries its segments inline, plus the cached intelligence for
        that date: weather, an FX snapshot and destination notes, each with its
        own `updated_at` so a client can tell fresh data from stale.
      parameters:
        - { name: trip_id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: id, in: path, required: true, description: 'The day number (1, 2, 3 …).', schema: { type: integer, minimum: 1 } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TripDayDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/trips/{trip_id}/segments/{id}:
    get:
      tags: [Trips]
      summary: Read one segment
      description: |
        A segment is one booked thing inside a day: a flight, a hotel night, a
        transfer, an activity. `segment_type` says which, and the type-specific
        detail lives in `display_data` / `provider_data` / `metadata` rather
        than in fixed columns. A flight additionally carries flat
        `flight_number` / `airline_code` / airport / scheduled-time fields.

        Money is included where the key's role can see it: `price_amount`,
        `cost_amount` and the commission block are each gated separately.
      parameters:
        - { name: trip_id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TripSegmentDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/trips/{trip_id}/shares:
    post:
      tags: [Trips]
      summary: Mint a share link
      description: |
        Creates a public itinerary URL the traveller can open with no account.
        The whole body is optional: with none at all you get a view-only
        itinerary link that expires in 30 days.

        **The token is returned once.** Later reads publish the `url` but not
        the bare token, the same posture as an API key. Store the `url` if you
        need it again; otherwise revoke and mint a new one.

        `show_prices`, `show_costs` and `show_commission` decide what the
        traveller sees on the page. Leave the last two off for a client-facing
        link.
      parameters:
        - { name: trip_id, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                share: { $ref: '#/components/schemas/TripShareWrite' }
      responses:
        '201':
          description: Minted
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/TripShareMint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/trips/{trip_id}/shares/{id}:
    delete:
      tags: [Trips]
      summary: Revoke a share link
      description: 'The URL stops resolving immediately. There is no undo; mint a new link instead.'
      parameters:
        - { name: trip_id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '204': { description: Revoked }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/trips/{trip_id}/notifications:
    get:
      tags: [Trips]
      summary: Reminder timeline for a trip
      description: |
        What is queued to go out about this trip and what already went: the
        pre-departure checklist, the check-in nudge, the day-of note, the
        welcome home. This is the delivery schedule, not the bell.

        Read-state is not here. It lives on the separate notification record at
        `GET /api/v1/notifications`.
      parameters:
        - { name: trip_id, in: path, required: true, schema: { type: string, format: uuid } }
        - { name: status, in: query, schema: { type: string, enum: [pending, delivered, suppressed, failed, expired] } }
        - { name: audience, in: query, schema: { type: string, enum: [advisor, traveler, both] } }
        - { name: scope, in: query, schema: { type: string, enum: [upcoming, past] } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TripNotificationSchedule' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/communications:
    get:
      tags: [Communications]
      summary: List messages
      description: |
        Newest first, across every channel. `unread_count` is the account-wide
        figure for the key's principal and is returned beside the page, so a
        badge needs no second call.
      parameters:
        - { name: q, in: query, schema: { type: string } }
        - { name: customer_id, in: query, schema: { type: string, format: uuid } }
        - { name: trip_id, in: query, schema: { type: string, format: uuid } }
        - { name: channel, in: query, schema: { type: string, example: email } }
        - { name: direction, in: query, schema: { type: string, enum: [inbound, outbound, internal] } }
        - { name: thread_id, in: query, description: 'Returns the whole conversation.', schema: { type: string } }
        - { name: scope, in: query, schema: { type: string, enum: [unread] } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, page, unread_count]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Communication' }
                  page: { $ref: '#/components/schemas/CursorPage' }
                  unread_count: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    post:
      tags: [Communications]
      summary: Send or log a message
      description: |
        Two jobs in one endpoint, told apart by `direction`.

        `outbound` on `email`, `sms` or `whatsapp` queues a real send. The
        response comes back before the transport runs, so the row you get is
        the queued one: watch `status`, `sent_at` and `failed_at` to learn the
        outcome. `internal` (the default when `direction` is omitted) records a
        note against the customer or trip and sends nothing. `inbound` files a
        message your own system received.

        Reply by carrying the parent's `thread_id` and setting `in_reply_to`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [communication]
              properties:
                communication: { $ref: '#/components/schemas/CommunicationWrite' }
      responses:
        '201':
          description: Created, and queued when outbound
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CommunicationDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/communications/{id}:
    get:
      tags: [Communications]
      summary: Read a message
      description: 'Adds the full body, the recipient lists and the linked customer and trip.'
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/CommunicationDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/communications/{id}/mark_read:
    post:
      tags: [Communications]
      summary: Mark a message read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Marked
          content:
            application/json:
              schema:
                type: object
                required: [data, unread_count]
                properties:
                  data: { $ref: '#/components/schemas/CommunicationDetail' }
                  unread_count: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/notifications:
    get:
      tags: [Notifications]
      summary: List bell notifications
      description: 'Newest first, scoped to the company the key''s principal is reading.'
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, page, unread_count]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Notification' }
                  page: { $ref: '#/components/schemas/CursorPage' }
                  unread_count: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/notifications/{id}/mark_read:
    post:
      tags: [Notifications]
      summary: Mark one read
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Marked
          content:
            application/json:
              schema:
                type: object
                required: [data, unread_count]
                properties:
                  data: { $ref: '#/components/schemas/NotificationDetail' }
                  unread_count: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/notifications/mark_all_read:
    post:
      tags: [Notifications]
      summary: Mark everything read
      description: 'Answers with the new count, which is always `0`.'
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Cleared
          content:
            application/json:
              schema:
                type: object
                required: [unread_count]
                properties:
                  unread_count: { type: integer, example: 0 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/reports:
    get:
      tags: [Reports]
      summary: Preset catalogue
      description: |
        Every built-in report the account's plan includes, with the filters it
        accepts and the columns it returns. Read this first and build your
        query from it rather than hardcoding keys: the catalogue is the
        contract, and it shrinks or grows with the plan.

        A fixed set of definitions rather than records, so there is no `page`.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ReportPreset' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/reports/{id}:
    get:
      tags: [Reports]
      summary: Run a preset report
      description: |
        `id` is a preset `key` from the catalogue. Filters go in as
        `filters[key]=value`, for example
        `?filters[from]=2026-01-01&filters[to]=2026-03-31`.

        The result is unwrapped: `rows`, `columns` and the totals sit at the
        top level, not under `data`. Money in `rows` follows the key's role, so
        a masked column comes back empty rather than the row being withheld.

        A report the plan does not include answers `403 plan_gated`; an
        unrecognised key answers `404 not_found`.
      parameters:
        - { name: id, in: path, required: true, description: 'A preset key.', schema: { type: string, example: ar_aging } }
        - name: filters
          in: query
          style: deepObject
          explode: true
          description: 'Per-preset; the catalogue publishes which keys each one takes.'
          schema:
            type: object
            additionalProperties: { type: string }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportResult' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The plan does not include this report
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [plan_gated, forbidden] }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/saved_reports:
    get:
      tags: [Reports]
      summary: List saved reports
      description: |
        The agency's own reports from the report builder, by name. Requires the
        `reports_builder` plan feature; without it the whole endpoint answers
        `403 plan_gated`.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, page]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/SavedReport' }
                  page: { $ref: '#/components/schemas/CursorPage' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The plan does not include the report builder
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [plan_gated, forbidden] }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/saved_reports/{id}:
    get:
      tags: [Reports]
      summary: Run a saved report
      description: |
        Runs the stored definition and returns the same result shape a preset
        does, plus `saved_report_id` and `name`. The filters are the ones saved
        with the report; they are not overridable here.
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ReportResult'
                  - type: object
                    properties:
                      saved_report_id: { type: string, format: uuid }
                      name: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: The plan does not include the report builder
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [plan_gated, forbidden] }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/performance:
    get:
      tags: [Insights]
      summary: Post-trip quality snapshot
      description: |
        Survey outcomes over a trailing window: how many invitations went out,
        how many were answered, the NPS that came back, the average segment
        rating, a per-advisor breakdown, the suppliers that scored best and the
        detractors worth calling.

        Computed fresh on each call. Leave `advisor_id` off for the whole
        account.
      parameters:
        - name: window_days
          in: query
          description: 'Anything outside the allowed set falls back to 90.'
          schema: { type: integer, enum: [30, 60, 90, 180, 365], default: 90 }
        - { name: advisor_id, in: query, schema: { type: string, format: uuid } }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/PerformanceSnapshot' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /api/v1/retention:
    get:
      tags: [Insights]
      summary: Rebooking opportunities
      description: |
        The forward view on past customers: trips hitting their one-year
        anniversary inside the window, trips that ended about six months ago
        and are due a nudge, the outreach already sent, and the referral codes
        pulling their weight.

        Each list is capped (50 anniversaries, 50 rebooks, 20 touches, 10
        referral codes) and there is no cursor: widen or narrow with
        `window_days`.
      parameters:
        - name: window_days
          in: query
          description: 'Clamped to 7-365.'
          schema: { type: integer, minimum: 7, maximum: 365, default: 60 }
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/RetentionSnapshot' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

components:
  parameters:
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque; take it from the previous response's `page.next_cursor`.
      schema: { type: string }
    Limit:
      name: limit
      in: query
      required: false
      description: Rows per page.
      schema: { type: integer, default: 25, minimum: 1, maximum: 100 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Your own unique string for this write (a UUID is the obvious choice), max
        255 characters. Optional: omit it and nothing changes. Send it and a repeat
        of the same request returns this call's response verbatim, with
        `Idempotent-Replay: true`, instead of writing again.
      schema:
        type: string
        maxLength: 255
        example: 6f0a2f3e-4c1b-4a2e-9f3d-7b1c2d3e4f50

    ImportEntity:
      name: entity
      in: path
      required: true
      description: |
        A key from `GET /api/v1/imports/entities` on the write endpoints. The read
        endpoints (`/records`, `/records/{external_id}`) additionally accept the
        read-only entities `charters`, `visa_cases` and `events`, which broadcast
        webhooks but cannot be pushed.
      schema:
        type: string
        example: hotel_stays

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: sek_<secret>
      description: API key from Settings → API Keys (`sek_` prefix).

  responses:
    Unauthorized:
      description: Missing or invalid Bearer token
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                example: Unauthorized
              code:
                type: string
                example: unauthorized
    InsufficientScope:
      description: The key lacks the required scope, or the plan doesn't include API access
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              code:
                type: string
                enum: [insufficient_scope, plan_upgrade_required, forbidden]
    ImportRejected:
      description: The request itself was refused before anything was staged
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string }
              code:
                type: string
                enum: [no_rows, too_many_rows, no_company, unknown_entity, unknown_branch]
    Forbidden:
      description: Policy denied or account has no owner
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
              code:
                type: string
                enum: [forbidden, misconfigured]
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                example: Not found
              code:
                type: string
                example: not_found
    ValidationFailed:
      description: |
        The request was understood but the record could not be written.
        `details` maps each rejected attribute to its reasons, so a client can
        put the message next to the field that caused it.
      content:
        application/json:
          schema:
            type: object
            required: [error, code]
            properties:
              error: { type: string, example: Validation failed }
              code: { type: string, example: validation_failed }
              details:
                type: object
                additionalProperties:
                  type: array
                  items: { type: string }
                example:
                  email: ["is invalid"]
                  name: ["can't be blank"]

    TooManyRequests:
      description: Rate limit exceeded
      headers:
        Retry-After:
          description: Seconds until the window rolls.
          schema:
            type: string
        X-RateLimit-Limit:
          schema:
            type: string
        X-RateLimit-Remaining:
          schema:
            type: string
        X-RateLimit-Reset:
          description: UTC epoch second at which the window rolls.
          schema:
            type: string
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string

  schemas:
    CursorPage:
      type: object
      description: |
        The cursor for the list beside it. Re-send the same request with
        `?cursor=<next_cursor>` while `has_more` is true. `next_cursor` is `null`
        on the last page.
      required: [next_cursor, has_more, limit]
      properties:
        next_cursor: { type: string, nullable: true }
        has_more: { type: boolean }
        limit: { type: integer, example: 25 }

    ImportEntitySummary:
      type: object
      properties:
        key: { type: string, example: hotel_stays }
        label: { type: string, example: Hotel stays }
        model: { type: string, example: HotelStay }
        scope: { type: string, enum: [company, account] }
        dependency_order:
          type: integer
          description: Import leaves first — customers/suppliers (1) before the bookings that reference them.
        idempotency:
          type: object
          properties:
            external_id_required: { type: boolean, example: true }
            mode:
              type: string
              enum: [upsert, create_once]
              description: |
                `upsert` — re-sending a source id updates the record (people, brought-forward documents).
                `create_once` — re-sending skips it (bookings; the CreateServices have no update path).
            key: { type: string, example: '(source_system, hotel_stays, external_id)' }
        endpoints:
          type: object
          properties:
            validate: { type: string }
            import: { type: string }

    ImportEntitySchema:
      allOf:
        - $ref: '#/components/schemas/ImportEntitySummary'
        - type: object
          properties:
            fields:
              type: array
              items: { $ref: '#/components/schemas/ImportField' }
            references:
              type: array
              items:
                type: object
                properties:
                  key: { type: string, example: customer_external_id }
                  target_entity: { type: string, example: customers }
                  match_by:
                    type: array
                    items: { type: string }
                    description: Tried in order — the source id first, then the target's natural keys.
                  on_missing:
                    type: string
                    enum: [error, skip]
                    description: '`skip` imports the record unlinked; `error` fails the row.'
            sample:
              type: object
              description: A complete body you can POST as-is.
              additionalProperties: true

    ImportField:
      type: object
      properties:
        key: { type: string }
        type:
          type: string
          enum:
            [string, email, phone, tax_number, currency, date, datetime, money,
             decimal, integer, boolean, enum, taxonomy, reference, list]
        required: { type: boolean }
        pii: { type: boolean }
        enum_values:
          type: array
          items: { type: string }
          description: Enforced allowlist.
        suggested_values:
          type: array
          items: { type: string }
          description: Standard catalogue values — documented, NOT enforced (your account may add its own).
        taxonomy_kind:
          type: string
          description: The /settings/taxonomies list that governs this value.
        note: { type: string }
        items:
          type: array
          description: For `list` fields — the shape of each array element (may nest).
          items: { $ref: '#/components/schemas/ImportField' }

    ImportRequest:
      type: object
      required: [rows]
      properties:
        rows:
          type: array
          minItems: 1
          maxItems: 500
          description: |
            One object per record. Keys are the entity's field keys — unknown keys are
            dropped and reported back in `ignored_fields`. Every row must carry `external_id`.
          items:
            type: object
            additionalProperties: true
        row:
          type: object
          description: Convenience alternative to `rows` for pushing a single record.
          additionalProperties: true
        source_system:
          type: string
          default: api
          description: Namespaces your ids, so two source systems can reuse the same id.
        company_id: { type: string, format: uuid }
        branch_id:
          type: string
          format: uuid
          description: |
            The branch the rows are filed under, from `GET /api/v1/workspace`. Omit it
            and they are filed under the key owner's own branch — the one marked
            `headquarters` on most accounts. An id that is not a branch of this
            account is refused with `422 unknown_branch`; a booking filed under no
            branch at all would be invisible to the branch that took it.
        mode:
          type: string
          enum: [sync, async]
          description: Defaults to sync at or below the sync row limit, async above it.
        on_conflict:
          type: string
          enum: [skip, update]
          default: skip
          description: |
            What to do when a create-once record already exists for the row's `external_id`.

            `skip` (default) leaves it alone, so a replay never silently rewrites a booking.
            `update` amends it through the entity's allowlisted fields — `status` moves
            through the state machine, and a `cancelled` status runs the vertical's cancel
            service (reversing the customer invoice and voiding the supplier bill) instead
            of writing the field. An invoiced or cancelled record is frozen except for
            `notes`, and an amendment that touches anything else is refused, not
            half-applied. Pricing is never amendable — cancel and re-create, or price it
            in-app.
        decimal_separator:
          type: string
          enum: [',', '.']
          description: Only needed when your source writes amounts as `1.234,56`.

    ImportResponse:
      type: object
      properties:
        import: { $ref: '#/components/schemas/ImportBatch' }
        ignored_fields:
          type: array
          items: { type: string }
          description: Keys you sent that this entity doesn't declare — a typo reads as a warning, not a silent no-op.
        previous_batch_id:
          type: string
          format: uuid
          nullable: true
          description: An earlier batch with a byte-identical payload. Informational only.
        results:
          type: array
          description: Per-row outcome. Omitted on a queued (202) commit — read it from the batch instead.
          items: { $ref: '#/components/schemas/ImportRowResult' }

    ImportBatch:
      type: object
      properties:
        id: { type: string, format: uuid }
        entity: { type: string }
        source_system: { type: string }
        status:
          type: string
          enum: [validating, ready, importing, completed, partial, failed]
        mode: { type: string, enum: [validate, commit] }
        async: { type: boolean }
        company_id: { type: string, format: uuid, nullable: true }
        stats:
          type: object
          properties:
            total: { type: integer }
            will_create: { type: integer }
            will_update: { type: integer }
            created: { type: integer }
            updated: { type: integer }
            skipped: { type: integer }
            invalid: { type: integer }
            failed: { type: integer }
        error: { type: string, nullable: true }
        created_at: { type: string, format: date-time }
        links:
          type: object
          properties:
            self: { type: string }
            rows: { type: string }

    TicketImport:
      type: object
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [parsed, duplicate, failed] }
        provider: { type: string, example: amadeus }
        format: { type: string, example: amadeus_air }
        filename: { type: string, nullable: true }
        ticket_sale_id: { type: string, format: uuid, nullable: true }
        error: { type: string, nullable: true }
        captured_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        duplicate_ticket_numbers:
          type: array
          nullable: true
          description: On a ticket-grain duplicate, the document numbers this capture repeats.
          items: { type: string }

    ImportedRecord:
      type: object
      properties:
        entity: { type: string, example: hotel_stays }
        external_id: { type: string, description: Your own id in the source system. }
        source_system: { type: string }
        type: { type: string, example: HotelStay }
        id: { type: string, format: uuid }
        status: { type: string }
        locked:
          type: boolean
          description: Invoiced or cancelled — amendments other than `notes` will be refused.
        lock_reason: { type: string, enum: [invoiced, cancelled], nullable: true }
        currency: { type: string }
        totals:
          type: object
          description: Decimal STRINGS, never floats.
          additionalProperties: { type: string }
        invoices:
          type: array
          nullable: true
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              number: { type: string }
              status: { type: string }
              currency: { type: string }
              total: { type: string }
              balance: { type: string }
        changed:
          type: array
          description: |
            Webhook deliveries only — which watched fields moved. Usually a column
            name (`status`, `supplier_confirmation_no`); for a change the columns
            cannot show, it is the path in THIS body that moved:
            `detail.passengers` when a ticket is numbered, `detail.cabins` when a
            cabin is allocated, `detail.voucher_number` when a voucher is stamped.
          items: { type: string }
        detail:
          type: object
          nullable: true
          description: |
            The entity-specific half, present only for entities that publish one —
            `GET /api/v1/imports/entities/{key}` lists the keys under `detail_fields`.
            Absent entirely when the entity has none, never an empty object.

            Every broadcasting entity publishes one. Each carries what the agency
            creates or changes AFTER your push, which is what your own copy cannot
            have. Six of them begin with the same three: `voucher_number`,
            **`supplier_confirmation_no`** and `supplier` (the one actually booked,
            which may not be the one you named: an unresolved
            `supplier_external_id` is skipped rather than failing the row).

            * `tickets` — `pnr`, `gds_pnr`, `settlement_channel`, `passengers[]`
              (name, type, **ticket_number**, status) and `legs[]` (flight, from, to,
              departure_at, arrival_at, cabin_class, booking_class, status).
            * `hotel_stays` — those three, the confirmed
              `check_in_on` / `check_out_on`, and `rooms[]`
              (name, room_type, board_basis, booked_pax, capacity, plus its own dates
              when the room leaves the stay's window).
            * `transfers` — those three, plus `pickup_at`, `pickup_location`,
              `dropoff_location`, `vehicle` (name, plate), `driver`, and `flight`
              (status, delay_minutes, suggested_pickup_at) from our flight tracker.
            * `cruises` — those three, plus `ship_name`, `embark_on`,
              `disembark_on` and `cabins[]` (**cabin_number**, category, deck,
              booked_pax, capacity).
            * `car_rentals` — those three, plus `vehicle_class`, `pickup_at`,
              `return_at`, `pickup_location`, `dropoff_location`.
            * `charters` — those three, plus `vessel` (the boat actually sailing,
              which a supplier may swap), `starts_on`, `ends_on`, `guest_count`,
              `option_date`.
            * `visa_cases` — those three, plus **`state`** and **`outcome`**. This
              entity has NO top-level `status`: its lifecycle column is `state`, so
              the key is absent from the generic half rather than null. Also
              `destination_country`, `visa_class`, `consulate_code`, `priority`,
              `target_submission_date`, `actual_decision_date`.
            * `tour_bookings` — `voucher_number`, `supplier_confirmation_no`,
              `party_size` and the departure the seat sits on
              (`departure_title`, `departure_starts_on`, `departure_state`). No
              `supplier`: the operator belongs to the departure, not the seat.
            * `tour_departures` — inventory, so no voucher and no top-level
              `status` either: **`inventory_state`** is the lifecycle word, with
              `title`, `starts_on`, `ends_on`, `max_capacity` and `supplier`. Seats
              sold is not published — you already know your own.
            * `events` — no voucher and no supplier of its own (a job runs on
              dozens, each on its own bill). `title`, `event_type`, `venue_name`,
              `venue_address`, `city`, `country_code`, `starts_on`, `ends_on`,
              `attendee_count`, `option_date`.

            `ticket_number`, `supplier_confirmation_no` and `cabin_number` are the
            fields this read-back exists for: they are assigned by the agency or its
            supplier after your push and cannot be sent in. Traveller identity
            documents (passport, national id, date of birth), guest names and the
            bill-to block are deliberately NOT carried — this body is also POSTed to
            every registered webhook URL. For the same reason a visa case omits
            `passport_country` and `vfs_reference_number`, the handle the applicant
            tracks their own application with.
          additionalProperties: true
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ImportRowResult:
      type: object
      properties:
        row: { type: integer, description: 1-based position in the batch you sent. }
        external_id: { type: string, nullable: true }
        status:
          type: string
          enum: [will_create, will_update, imported, updated, skipped, invalid, failed]
        record:
          type: object
          nullable: true
          properties:
            type: { type: string, example: HotelStay }
            id: { type: string, format: uuid }
        errors:
          type: object
          nullable: true
          description: |
            Field → messages. A nested failure names its position, e.g.
            `{"cost_lines": ["cost_lines[0].amount: not an amount: \"abc\""]}`.
          additionalProperties:
            type: array
            items: { type: string }

    WebhookSubscriptionListItem:
      type: object
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        events:
          type: array
          items:
            type: string
            enum:
              - record.created
              - record.updated
              - record.deleted
        entities:
          type: array
          description: |
            Record types this endpoint receives. `[]` means ALL of them, not
            none — including types added after the subscription was created. Same
            keys as `/api/v1/imports/{entity}`, whether or not that entity accepts
            a push: `charters`, `visa_cases` and `events` are read-only over the
            import endpoint and still broadcast.
          items:
            type: string
            enum:
              - tickets
              - hotel_stays
              - transfers
              - cruises
              - car_rentals
              - tour_bookings
              - tour_departures
              - charters
              - visa_cases
              - events
        active: { type: boolean }
        created_at: { type: string, format: date-time }

    WebhookCreateRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          maxLength: 2048
        events:
          type: array
          items:
            type: string
            enum:
              - record.created
              - record.updated
              - record.deleted
        entities:
          type: array
          description: |
            Record types this endpoint receives. `[]` means ALL of them, not
            none — including types added after the subscription was created. Same
            keys as `/api/v1/imports/{entity}`, whether or not that entity accepts
            a push: `charters`, `visa_cases` and `events` are read-only over the
            import endpoint and still broadcast.
          items:
            type: string
            enum:
              - tickets
              - hotel_stays
              - transfers
              - cruises
              - car_rentals
              - tour_bookings
              - tour_departures
              - charters
              - visa_cases
              - events

    WebhookCreateResponse:
      type: object
      required: [id, url, events, entities, signing_secret, created_at]
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        entities:
          type: array
          description: '`[]` means every booking type.'
          items: { type: string }
        signing_secret:
          type: string
          description: Shown once; `whsec_` prefix
        created_at: { type: string, format: date-time }

    UserStub:
      type: object
      description: A seat on the account, as referenced from other records.
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        email: { type: string, nullable: true }

    Me:
      type: object
      description: |
        The key's own context. `user` is the principal it acts as, which for an
        unbound key is the account owner.
      properties:
        user:
          type: object
          properties:
            id: { type: string, format: uuid }
            name: { type: string, nullable: true }
            email: { type: string }
            role: { type: string, example: owner }
            locale: { type: string, example: tr }
            ai_response_locale: { type: string, nullable: true }
            verified: { type: boolean }
        account:
          type: object
          properties:
            id: { type: string, format: uuid }
            external_account_id:
              type: integer
              description: The number that appears in browser URLs for this account.
              example: 1000001
            name: { type: string }
        current_branch:
          type: object
          nullable: true
          description: 'Always `null` for an API key: a key belongs to the account, not to a desk. Name a branch per call instead.'
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            code: { type: string, nullable: true }
            is_headquarters: { type: boolean }
            company_id: { type: string, format: uuid }
            company_name: { type: string, nullable: true }
        companies:
          type: array
          description: Every company the principal can reach, default first, each with its branches.
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              name: { type: string }
              company_type: { type: string, nullable: true }
              is_default: { type: boolean }
              active: { type: boolean }
              branches:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string, format: uuid }
                    name: { type: string }
                    code: { type: string, nullable: true }
                    is_headquarters: { type: boolean }
                    active: { type: boolean }
        policies:
          type: object
          description: What the principal may do, as booleans.
          additionalProperties: { type: boolean }
        features:
          type: object
          description: |
            Plan features, as booleans. `api_access` is the one that decides
            whether a key works at all: false here means every authenticated
            call answers `403 plan_upgrade_required`.
          additionalProperties: { type: boolean }
        billing:
          type: object
          nullable: true
          properties:
            status: { type: string, example: active }
            plan_slug: { type: string, nullable: true }
            plan_name: { type: string, nullable: true }
            trialing: { type: boolean }
            active: { type: boolean }
            trial_ends_at: { type: string, format: date-time, nullable: true }
            cancel_at_period_end: { type: boolean }
        server_time:
          type: string
          format: date-time
          description: Useful for checking clock drift before you sign or schedule anything.

    Customer:
      type: object
      description: |
        List shape. `name`, `email` and `phone` are personal data: a key whose
        role cannot read PII gets `null`, and a customer carrying a restricted
        tag has its name masked too.
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        customer_type: { type: string, example: individual }
        email: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        lifecycle_stage: { type: string, nullable: true }
        vip_tier: { type: string, nullable: true }
        active: { type: boolean }
        tags:
          type: array
          items: { type: string }
        company_id: { type: string, format: uuid }

    CustomerDetail:
      allOf:
        - $ref: '#/components/schemas/Customer'
        - type: object
          properties:
            first_name: { type: string, nullable: true }
            last_name: { type: string, nullable: true }
            legal_name: { type: string, nullable: true }
            tax_number: { type: string, nullable: true }
            tax_office: { type: string, nullable: true }
            tax_office_code: { type: string, nullable: true }
            tax_scheme: { type: string, nullable: true }
            address:
              type: object
              nullable: true
              description: Free-form address object.
              additionalProperties: true
            birthday: { type: string, format: date, nullable: true }
            anniversary: { type: string, format: date, nullable: true }
            preferred_channel: { type: string, nullable: true }
            opted_in_marketing: { type: boolean }
            opted_in_at: { type: string, format: date-time, nullable: true }
            code: { type: string, nullable: true }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }

    CustomerWrite:
      type: object
      properties:
        name: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        email: { type: string, format: email }
        phone: { type: string }
        customer_type: { type: string, example: individual }
        legal_name: { type: string }
        tax_number: { type: string }
        tax_office: { type: string }
        tax_office_code: { type: string }
        birthday: { type: string, format: date }
        anniversary: { type: string, format: date }
        vip_tier: { type: string }
        lifecycle_stage: { type: string }
        preferred_channel: { type: string }
        opted_in_marketing: { type: boolean }
        code: { type: string }
        active: { type: boolean }
        company_id: { type: string, format: uuid }
        address:
          type: object
          additionalProperties: true
        tags:
          type: array
          description: Replaces the whole set. Omit the key to leave tags alone.
          items: { type: string }

    CustomerNote:
      type: object
      properties:
        id: { type: string, format: uuid }
        body: { type: string }
        pinned: { type: boolean }
        pinned_at: { type: string, format: date-time, nullable: true }
        author:
          allOf: [{ $ref: '#/components/schemas/UserStub' }]
          nullable: true
        attachments_count: { type: integer }
        attachments:
          type: array
          description: Detail only. `url` is short-lived; fetch it again rather than storing it.
          items:
            type: object
            properties:
              id: { type: string }
              filename: { type: string }
              content_type: { type: string, nullable: true }
              byte_size: { type: integer }
              url: { type: string }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    CustomerSuggestion:
      type: object
      properties:
        kind: { type: string, description: 'What kind of idea this is, for example a similar trip or a template.' }
        subject:
          type: object
          description: The thing being suggested.
          additionalProperties: true
        reason: { type: string, description: 'Why it surfaced, in plain language.' }
        payload:
          type: object
          description: Supporting figures for the suggestion.
          additionalProperties: true

    Lead:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        email: { type: string, nullable: true }
        phone: { type: string, nullable: true }
        stage: { type: string, example: inbox }
        score: { type: integer, nullable: true }
        source: { type: string, nullable: true }
        estimated_value:
          type: string
          nullable: true
          description: Decimal as a string, to avoid float rounding.
          example: '12500.00'
        currency: { type: string, nullable: true, example: EUR }
        destinations:
          type: array
          items: { type: string }
        party_size: { type: integer, nullable: true }
        target_departure_on: { type: string, format: date, nullable: true }
        target_return_on: { type: string, format: date, nullable: true }
        assigned_to:
          allOf: [{ $ref: '#/components/schemas/UserStub' }]
          nullable: true
        customer_id: { type: string, format: uuid, nullable: true, description: 'Set once the lead converts.' }
        trip_id: { type: string, format: uuid, nullable: true }
        updated_at: { type: string, format: date-time }

    LeadDetail:
      allOf:
        - $ref: '#/components/schemas/Lead'
        - type: object
          properties:
            notes: { type: string, nullable: true }
            preferences:
              type: object
              additionalProperties: true
            qualified_at: { type: string, format: date-time, nullable: true }
            won_at: { type: string, format: date-time, nullable: true }
            lost_at: { type: string, format: date-time, nullable: true }
            lost_reason: { type: string, nullable: true }
            scored_at: { type: string, format: date-time, nullable: true }
            utm:
              type: object
              description: Only the keys that were captured.
              properties:
                source: { type: string }
                medium: { type: string }
                campaign: { type: string }
                term: { type: string }
                content: { type: string }
            created_at: { type: string, format: date-time }

    LeadWrite:
      type: object
      properties:
        name: { type: string }
        email: { type: string, format: email }
        phone: { type: string }
        source: { type: string }
        party_size: { type: integer }
        estimated_value: { type: string, example: '12500.00' }
        currency: { type: string, example: EUR }
        target_departure_on: { type: string, format: date }
        target_return_on: { type: string, format: date }
        notes: { type: string }
        assigned_to_id: { type: string, format: uuid }
        company_id: { type: string, format: uuid }
        destinations:
          type: array
          items: { type: string }
        preferences:
          type: object
          additionalProperties: true

    Task:
      type: object
      properties:
        id: { type: string, format: uuid }
        subject: { type: string }
        priority: { type: string, nullable: true }
        due_at: { type: string, format: date-time, nullable: true }
        completed_at: { type: string, format: date-time, nullable: true }
        overdue: { type: boolean }
        taskable_type: { type: string, nullable: true, example: Trip }
        taskable_id: { type: string, format: uuid, nullable: true }
        taskable_label:
          type: string
          nullable: true
          description: The linked record rendered as text, so a row needs no second fetch.
        assigned_to:
          allOf: [{ $ref: '#/components/schemas/UserStub' }]
          nullable: true

    TaskDetail:
      allOf:
        - $ref: '#/components/schemas/Task'
        - type: object
          properties:
            body: { type: string, nullable: true }
            reminder_offsets:
              type: array
              description: Minutes before `due_at`.
              items: { type: integer }
            owner:
              allOf: [{ $ref: '#/components/schemas/UserStub' }]
              nullable: true
            completed_by:
              allOf: [{ $ref: '#/components/schemas/UserStub' }]
              nullable: true
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }

    TaskWrite:
      type: object
      properties:
        subject: { type: string }
        body: { type: string }
        priority: { type: string }
        due_at: { type: string, format: date-time }
        assigned_to_id: { type: string, format: uuid }
        taskable_type: { type: string, example: Trip }
        taskable_id: { type: string, format: uuid }
        reminder_offsets:
          type: array
          items: { type: integer }

    Trip:
      type: object
      properties:
        id: { type: string, format: uuid }
        reference_number: { type: string, nullable: true }
        title: { type: string }
        status: { type: string }
        trip_type: { type: string, nullable: true }
        start_date: { type: string, format: date, nullable: true }
        end_date: { type: string, format: date, nullable: true }
        duration_nights: { type: integer, nullable: true }
        destinations:
          type: array
          items: { type: string }
        primary_currency: { type: string, nullable: true }
        cover_image_url: { type: string, nullable: true }
        assigned_advisor:
          allOf: [{ $ref: '#/components/schemas/UserStub' }]
          nullable: true
        primary_customer:
          type: object
          nullable: true
          properties:
            id: { type: string, format: uuid }
            name: { type: string, nullable: true }
        traveler_count: { type: integer }
        days_until:
          type: integer
          nullable: true
          description: Days from today to departure. Negative once the trip has started.
        updated_at: { type: string, format: date-time }

    TripDetail:
      allOf:
        - $ref: '#/components/schemas/Trip'
        - type: object
          properties:
            summary: { type: string, nullable: true }
            internal_notes: { type: string, nullable: true }
            settings:
              type: object
              additionalProperties: true
            notification_preferences:
              type: object
              additionalProperties: true
            account_id: { type: string, format: uuid }
            company_id: { type: string, format: uuid }
            customers:
              type: array
              items:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string, nullable: true }
                  email: { type: string, nullable: true }
            travelers:
              type: array
              items:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  customer_id: { type: string, format: uuid, nullable: true }
                  name: { type: string, nullable: true }
                  is_lead: { type: boolean }
            day_count: { type: integer }
            created_at: { type: string, format: date-time }

    TripDay:
      type: object
      properties:
        id: { type: string, format: uuid }
        day_number: { type: integer, description: 'The 1-based position. This is what `/days/{id}` takes.' }
        date: { type: string, format: date, nullable: true }
        title: { type: string, nullable: true }
        primary_location: { type: string, nullable: true }
        position: { type: integer }
        segment_count: { type: integer }

    TripDayDetail:
      allOf:
        - $ref: '#/components/schemas/TripDay'
        - type: object
          properties:
            description: { type: string, nullable: true }
            latitude: { type: number, nullable: true }
            longitude: { type: number, nullable: true }
            timezone: { type: string, nullable: true }
            cover_image_url: { type: string, nullable: true }
            intelligence:
              type: object
              description: |
                Cached context for this date. Each block carries its own
                `updated_at`, and a `null` `data` means it has not been fetched
                yet rather than that there is nothing to report.
              properties:
                weather:
                  type: object
                  properties:
                    data: { type: object, nullable: true, additionalProperties: true }
                    provider: { type: string, nullable: true }
                    updated_at: { type: string, format: date-time, nullable: true }
                currency:
                  type: object
                  properties:
                    data: { type: object, nullable: true, additionalProperties: true }
                    updated_at: { type: string, format: date-time, nullable: true }
                general:
                  type: object
                  properties:
                    data: { type: object, nullable: true, additionalProperties: true }
                    updated_at: { type: string, format: date-time, nullable: true }
            segments:
              type: array
              items: { $ref: '#/components/schemas/TripSegment' }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }

    TripSegment:
      type: object
      description: |
        One booked item inside a day. The flight fields appear only when
        `segment_type` is `flight`; the money fields only where the role can
        read them.
      properties:
        id: { type: string, format: uuid }
        trip_id: { type: string, format: uuid }
        trip_day_id: { type: string, format: uuid }
        segment_type: { type: string, example: hotel_checkin }
        status: { type: string, example: confirmed }
        title: { type: string, nullable: true }
        description: { type: string, nullable: true }
        position: { type: integer }
        start_time: { type: string, format: date-time, nullable: true }
        end_time: { type: string, format: date-time, nullable: true }
        duration_minutes: { type: integer, nullable: true }
        location_name: { type: string, nullable: true }
        location_address: { type: string, nullable: true }
        is_optional: { type: boolean }
        is_highlighted: { type: boolean }
        is_client_visible: { type: boolean }
        cover_image_url: { type: string, nullable: true }
        flight_number: { type: string, nullable: true, description: 'Flight segments only.' }
        airline_code: { type: string, nullable: true, description: 'Flight segments only.' }
        departure_airport: { type: string, nullable: true, description: 'Flight segments only.' }
        arrival_airport: { type: string, nullable: true, description: 'Flight segments only.' }
        scheduled_departure:
          type: string
          nullable: true
          description: 'Flight segments only. Local wall clock at the airport, not UTC.'
        scheduled_arrival: { type: string, nullable: true, description: 'Flight segments only.' }
        price_amount: { type: string, nullable: true, description: 'Decimal as a string. What the traveller is billed.' }
        currency: { type: string, nullable: true }

    TripSegmentDetail:
      allOf:
        - $ref: '#/components/schemas/TripSegment'
        - type: object
          properties:
            advisor_notes: { type: string, nullable: true }
            supplier_name: { type: string, nullable: true }
            confirmation_number: { type: string, nullable: true }
            booking_reference: { type: string, nullable: true }
            latitude: { type: number, nullable: true }
            longitude: { type: number, nullable: true }
            location_phone: { type: string, nullable: true }
            location_url: { type: string, nullable: true }
            display_data:
              type: object
              nullable: true
              description: Type-specific fields for rendering, keyed by `segment_type`.
              additionalProperties: true
            provider_data: { type: object, nullable: true, additionalProperties: true }
            metadata: { type: object, nullable: true, additionalProperties: true }
            image_gallery:
              type: array
              nullable: true
              items: { type: string }
            live_status: { type: string, nullable: true, description: 'Flight tracking, where enabled.' }
            live_data: { type: object, nullable: true, additionalProperties: true }
            live_updated_at: { type: string, format: date-time, nullable: true }
            tracking_enabled: { type: boolean }
            cost_amount: { type: string, nullable: true, description: 'What the supplier charges. Present only where the role can see costs.' }
            commission_amount: { type: string, nullable: true }
            commission_type: { type: string, nullable: true }
            commission_rate: { type: string, nullable: true }
            commission_currency: { type: string, nullable: true }
            payout_status: { type: string, nullable: true }
            created_at: { type: string, format: date-time }
            updated_at: { type: string, format: date-time }

    TripShareWrite:
      type: object
      properties:
        share_type: { type: string, default: itinerary }
        access_level: { type: string, default: view }
        requires_auth: { type: boolean }
        show_prices: { type: boolean, example: true }
        show_costs: { type: boolean, example: false, description: 'Leave off for a traveller-facing link.' }
        show_commission: { type: boolean, description: 'Leave off for a traveller-facing link.' }
        expires_at: { type: string, format: date-time, example: '2026-06-30T23:59:59Z', description: 'Defaults to 30 days out.' }
        pin_code: { type: string, description: 'Stored hashed. The page then asks for it.' }
        allowed_emails:
          type: array
          items: { type: string, format: email }

    TripShareMint:
      type: object
      properties:
        id: { type: string, format: uuid }
        trip_id: { type: string, format: uuid }
        token: { type: string, description: 'Returned once, at mint. Never published again.' }
        url: { type: string, format: uri, description: 'The link to hand the traveller.' }
        share_type: { type: string }
        access_level: { type: string }
        show_prices: { type: boolean }
        show_costs: { type: boolean }
        show_commission: { type: boolean }
        requires_auth: { type: boolean }
        allowed_emails:
          type: array
          items: { type: string }
        has_pin: { type: boolean }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    TripNotificationSchedule:
      type: object
      properties:
        id: { type: string, format: uuid }
        kind: { type: string, example: pre_departure_checklist }
        channel: { type: string, example: push }
        audience: { type: string, enum: [advisor, traveler, both] }
        status: { type: string, enum: [pending, delivered, suppressed, failed, expired] }
        scheduled_for: { type: string, format: date-time, nullable: true }
        delivered_at: { type: string, format: date-time, nullable: true }
        trip_day_id: { type: string, format: uuid, nullable: true }
        trip_segment_id: { type: string, format: uuid, nullable: true }
        user_id: { type: string, format: uuid, nullable: true }
        customer_id: { type: string, format: uuid, nullable: true }

    Communication:
      type: object
      properties:
        id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid, nullable: true }
        trip_id: { type: string, format: uuid, nullable: true }
        direction: { type: string, enum: [inbound, outbound, internal] }
        channel: { type: string, example: email }
        status: { type: string, nullable: true }
        subject: { type: string, nullable: true }
        preview: { type: string, nullable: true, description: 'First 160 characters of the plain body.' }
        content_masked:
          type: boolean
          description: 'True when the role cannot read message content. `subject`, `preview` and both bodies are then `null`.'
        from_address: { type: string, nullable: true }
        to_addresses:
          type: array
          items: { type: string }
        thread_id: { type: string, nullable: true }
        sent_at: { type: string, format: date-time, nullable: true }
        delivered_at: { type: string, format: date-time, nullable: true }
        read_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }
        unread: { type: boolean }

    CommunicationDetail:
      allOf:
        - $ref: '#/components/schemas/Communication'
        - type: object
          properties:
            body_plain: { type: string, nullable: true }
            body_html: { type: string, nullable: true }
            cc_addresses:
              type: array
              items: { type: string }
            reply_to: { type: string, nullable: true }
            in_reply_to: { type: string, nullable: true }
            external_id: { type: string, nullable: true, description: 'The provider''s own id for the message.' }
            failed_at: { type: string, format: date-time, nullable: true }
            failure_reason: { type: string, nullable: true }
            metadata: { type: object, nullable: true, additionalProperties: true }
            author:
              allOf: [{ $ref: '#/components/schemas/UserStub' }]
              nullable: true
            customer:
              type: object
              nullable: true
              properties:
                id: { type: string, format: uuid }
                name: { type: string, nullable: true }
            trip:
              type: object
              nullable: true
              properties:
                id: { type: string, format: uuid }
                title: { type: string }
            updated_at: { type: string, format: date-time }

    CommunicationWrite:
      type: object
      properties:
        direction:
          type: string
          enum: [inbound, outbound, internal]
          default: internal
          description: '`outbound` on a deliverable channel queues a real send.'
        channel: { type: string, example: email, description: '`email`, `sms` and `whatsapp` are deliverable.' }
        customer_id: { type: string, format: uuid, example: 9d2f7c3a-1b4e-4a7d-8e51-2c0f6b9a4d18 }
        trip_id: { type: string, format: uuid }
        company_id: { type: string, format: uuid }
        subject: { type: string, example: 'Your Cappadocia itinerary' }
        body_plain: { type: string, example: "Hello Ayşe, your itinerary is attached." }
        body_html: { type: string }
        from_address: { type: string }
        reply_to: { type: string }
        in_reply_to: { type: string }
        thread_id: { type: string, description: 'Carry the parent''s value to keep a reply in its conversation.' }
        external_id: { type: string }
        to_addresses:
          type: array
          example: [ayse@example.com]
          items: { type: string }
        cc_addresses:
          type: array
          items: { type: string }

    Notification:
      type: object
      properties:
        id: { type: string, format: uuid }
        category: { type: string }
        title: { type: string }
        body: { type: string, nullable: true }
        action_url: { type: string, nullable: true, description: 'Where the bell row points, relative to the app host.' }
        read_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    NotificationDetail:
      allOf:
        - $ref: '#/components/schemas/Notification'
        - type: object
          properties:
            company_id: { type: string, format: uuid, nullable: true }
            metadata: { type: object, nullable: true, additionalProperties: true }

    ReportPreset:
      type: object
      properties:
        key: { type: string, example: ar_aging, description: 'What you pass as `{id}` to run it.' }
        plan_feature: { type: string, description: 'The plan flag this report needs.' }
        filters:
          type: array
          description: The filters this preset accepts, as definitions rather than values.
          items:
            type: object
            additionalProperties: true
        columns:
          type: array
          description: The columns it returns, in order, with their labels and types.
          items:
            type: object
            additionalProperties: true

    ReportResult:
      type: object
      description: |
        The shape both report families return. Not wrapped in `data`: the
        result is the body. `rows` are objects keyed by the column keys in
        `columns`.
      properties:
        key: { type: string }
        columns:
          type: array
          items:
            type: object
            additionalProperties: true
        rows:
          type: array
          items:
            type: object
            additionalProperties: true
        summary:
          type: object
          description: Headline figures for the whole result.
          additionalProperties: true
        filters:
          type: object
          description: The filters actually applied, after defaults and clamping.
          additionalProperties: true
        generated_at: { type: string, format: date-time, nullable: true }
        pagination:
          type: object
          nullable: true
          description: 'Present only on presets that page. `null` means the result is whole.'
          additionalProperties: true
        group_by: { type: string, nullable: true, description: 'The grouping column, when the preset groups.' }
        groups:
          type: array
          nullable: true
          description: Ordered group headers with their per-group totals.
          items:
            type: object
            additionalProperties: true
        totals:
          type: object
          nullable: true
          description: 'Grand totals, including a per-currency breakdown where the rows are not in one currency.'
          additionalProperties: true

    SavedReport:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        entity: { type: string, nullable: true, description: 'What the report is built over.' }
        schedule_frequency: { type: string, nullable: true, description: '`null` when it is run on demand only.' }
        recipient_count: { type: integer }
        last_run_at: { type: string, format: date-time, nullable: true }

    PerformanceSnapshot:
      type: object
      properties:
        window_days: { type: integer, description: 'The window actually used, after the allowlist.' }
        advisor:
          allOf: [{ $ref: '#/components/schemas/UserStub' }]
          nullable: true
          description: 'Null when the snapshot covers the whole account.'
        invited: { type: integer, description: 'Survey invitations sent in the window.' }
        started: { type: integer }
        completed: { type: integer }
        response_rate: { type: number, nullable: true, description: 'Completed over invited, 0 to 1.' }
        nps: { type: number, nullable: true, description: 'Net promoter score, -100 to 100.' }
        avg_segment_rating: { type: number, nullable: true }
        per_advisor:
          type: array
          description: The same figures broken out per advisor.
          items:
            type: object
            additionalProperties: true
        top_suppliers:
          type: array
          items:
            type: object
            additionalProperties: true
        detractors:
          type: array
          description: Recent low scorers, worth a call.
          items:
            type: object
            additionalProperties: true
        complimentary:
          type: object
          nullable: true
          description: Goodwill and complimentary service figures for the window.
          additionalProperties: true

    RetentionSnapshot:
      type: object
      properties:
        window_days: { type: integer }
        upcoming:
          type: array
          description: 'Completed trips reaching their one-year anniversary inside the window. Capped at 50.'
          items:
            type: object
            properties:
              trip_id: { type: string, format: uuid }
              trip_title: { type: string }
              anniversary: { type: string, format: date }
              customer:
                type: object
                nullable: true
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string, nullable: true }
              advisor:
                allOf: [{ $ref: '#/components/schemas/UserStub' }]
                nullable: true
        overdue_rebooks:
          type: array
          description: 'Trips that ended about six months ago. Capped at 50.'
          items:
            type: object
            properties:
              trip_id: { type: string, format: uuid }
              trip_title: { type: string }
              end_date: { type: string, format: date, nullable: true }
              customer:
                type: object
                nullable: true
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string, nullable: true }
              advisor:
                allOf: [{ $ref: '#/components/schemas/UserStub' }]
                nullable: true
        recent_touches:
          type: array
          description: 'Outreach already sent, newest first. Capped at 20.'
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              kind: { type: string }
              status: { type: string }
              sent_at: { type: string, format: date-time, nullable: true }
              customer:
                type: object
                nullable: true
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string, nullable: true }
              trip:
                type: object
                nullable: true
                properties:
                  id: { type: string, format: uuid }
                  title: { type: string }
        top_referrals:
          type: array
          description: 'Active referral codes by conversions. Capped at 10.'
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              token: { type: string }
              label: { type: string, nullable: true }
              visits: { type: integer }
              conversions: { type: integer }
              customer:
                type: object
                nullable: true
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string, nullable: true }
              trip:
                type: object
                nullable: true
                properties:
                  id: { type: string, format: uuid }
                  title: { type: string }
