openapi: 3.1.0
info:
  title: Longevity.haus Public API
  version: '1.0.0'
  summary: Read-only endpoints for agents, integrators, and search indexes.
  description: |
    Longevity.haus is a medical-tourism platform for longevity and
    wellness services. These public endpoints expose the treatment
    catalog, search data, and currency rates used by the website and
    by AI agents that answer questions about providers, prices, and
    availability.

    All endpoints are read-only, unauthenticated, cacheable, and return
    JSON. Prices are stored in minor units (cents/satang/etc.); divide
    by the currency's minor-unit factor before display — see
    [/docs/currency](https://longevity.haus/docs/currency) if you need
    to convert.

    Machine-readable companions:
      - [/llms.txt](https://longevity.haus/llms.txt) — LLM-friendly site overview
      - [/llms-full.txt](https://longevity.haus/llms-full.txt) — consolidated single-fetch reference
      - [/.well-known/api-catalog](https://longevity.haus/.well-known/api-catalog) — RFC 9727 linkset
      - [/api/mcp](https://longevity.haus/api/mcp) — JSON-RPC 2.0 MCP server
      - [/.well-known/mcp/server-card.json](https://longevity.haus/.well-known/mcp/server-card.json) — MCP server card
      - [/.well-known/agent-card.json](https://longevity.haus/.well-known/agent-card.json) — A2A agent card
  contact:
    name: Longevity.haus engineering
    url: https://longevity.haus/contact
  license:
    name: Proprietary — data © Longevity.haus
servers:
  - url: https://longevity.haus
    description: Production
tags:
  - name: Catalog
    description: Treatment taxonomy and biomarker reference data.
  - name: Search
    description: Location, price, and slug indexes used to power search.
  - name: Currency
    description: Exchange rates for price conversion.
paths:
  /api/treatments.json:
    get:
      tags: [Catalog]
      operationId: getTreatmentsCatalog
      summary: Full treatment catalog — categories, biomarkers, service stats.
      description: |
        Returns the full treatment taxonomy (categories with their
        treatment types), the full biomarker reference list, and
        aggregate service stats (counts of providers per service type).
        Cached at the edge for 5 minutes with a 1-hour
        stale-while-revalidate window.
      responses:
        '200':
          description: Catalog payload.
          headers:
            X-RateLimit-Limit:
              $ref: '#/components/headers/XRateLimitLimit'
            X-RateLimit-Window:
              $ref: '#/components/headers/XRateLimitWindow'
            Cache-Control:
              $ref: '#/components/headers/CacheControl'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TreatmentsCatalog'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/DatabaseUnavailable'
  /api/search-data:
    get:
      tags: [Search]
      operationId: getSearchData
      summary: Global location, price, slug, and treatment-name indexes for search.
      description: |
        Returns four parallel indexes used to hydrate the search UI:
        a summary of all public locations, the minimum price per
        provider keyed by company slug, the set of service slugs
        offered per provider, and treatment names by company. Cached
        at the edge.
      responses:
        '200':
          description: Search data payload.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Window: { $ref: '#/components/headers/XRateLimitWindow' }
            Cache-Control: { $ref: '#/components/headers/CacheControl' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SearchData'
        '429': { $ref: '#/components/responses/RateLimited' }
        '500':
          description: Unexpected failure while aggregating the four underlying indexes.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              example:
                error:
                  code: internal_error
                  message: Failed to fetch search data.
                  hint: This is transient; retry with exponential backoff. If it persists for >1 min, check /api/health.
        '503': { $ref: '#/components/responses/DatabaseUnavailable' }
  /api/exchange-rates:
    get:
      tags: [Currency]
      operationId: getExchangeRates
      summary: EUR-base exchange rates.
      description: |
        Returns latest exchange rates keyed by ISO 4217 code with EUR
        as the base. Upstream: [Frankfurter](https://api.frankfurter.dev).
        Cached for 1 hour. EUR itself is always present with rate 1.
      responses:
        '200':
          description: Exchange-rate payload.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Window: { $ref: '#/components/headers/XRateLimitWindow' }
            Cache-Control: { $ref: '#/components/headers/CacheControl' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExchangeRates'
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: Upstream rate source unavailable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                frankfurterDown:
                  summary: Frankfurter API reachable but returned non-2xx
                  value:
                    error:
                      code: upstream_unavailable
                      message: Exchange rate provider (Frankfurter) is temporarily unreachable.
                      hint: The 1-hour stale-on-error cache had no fallback entry. Retry in a few minutes.
                noFallback:
                  summary: No cached rates and upstream failure
                  value:
                    error:
                      code: upstream_unavailable
                      message: Exchange rate provider (Frankfurter) is temporarily unavailable.
                      hint: Prices remain in native currency on the website until rates are restored.
  /api/health:
    get:
      tags: [Status]
      operationId: getHealth
      summary: Health check — Convex reachability + timestamp.
      description: |
        Lightweight liveness probe. Returns 200 with `status: "ok"` when
        the Convex read path is healthy, 503 with a standard ErrorEnvelope
        otherwise. Use this for agent-side retry logic; see /status for the
        human-readable version.
      responses:
        '200':
          description: Service healthy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HealthOk' }
        '503':
          description: Service unhealthy — Convex client missing or unreachable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
              examples:
                convexMissing:
                  summary: Platform bindings misconfigured
                  value:
                    error:
                      code: database_unavailable
                      message: Convex client is not configured for this deployment.
                      hint: Check platform.env bindings on Cloudflare Pages.
                convexUnreachable:
                  summary: Transient Convex read-path failure
                  value:
                    error:
                      code: database_unavailable
                      message: Convex read path timed out.
                      hint: Transient Convex unreachability. Retry after a few seconds.
  /api/mcp:
    post:
      tags: [MCP]
      operationId: mcpJsonRpc
      summary: MCP 1.0 JSON-RPC 2.0 endpoint (Streamable HTTP transport).
      description: |
        Model Context Protocol server endpoint. Accepts JSON-RPC 2.0
        bodies for: `initialize`, `notifications/initialized`, `ping`,
        `tools/list`, `tools/call`. See the server card at
        `/.well-known/mcp/server-card.json` for the full tool list.

        Origin header must be in an allowlist (longevity.haus, claude.ai,
        chat.openai.com, chatgpt.com, or absent for native clients).
        Request bodies > 64 KiB are rejected with HTTP 413.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [jsonrpc, method]
              properties:
                jsonrpc: { type: string, enum: ['2.0'] }
                id: { oneOf: [{ type: string }, { type: integer }, { type: 'null' }] }
                method: { type: string }
                params: { type: object }
      responses:
        '200':
          description: JSON-RPC response envelope (success or error).
          content:
            application/json:
              schema:
                type: object
                properties:
                  jsonrpc: { type: string, enum: ['2.0'] }
                  id: { oneOf: [{ type: string }, { type: integer }, { type: 'null' }] }
                  result: { type: object }
                  error:
                    type: object
                    properties:
                      code: { type: integer }
                      message: { type: string }
                      data: {}
        '202':
          description: Accepted — returned for JSON-RPC notifications (no body).
        '400':
          description: |
            Unsupported `MCP-Protocol-Version` header. Transport-level
            failure (request didn't reach JSON-RPC processing), returned
            as `MCPTransportError` not as a JSON-RPC error envelope.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MCPTransportError' }
              example:
                error:
                  code: unsupported_protocol
                  message: 'Unsupported MCP-Protocol-Version: 2024-01-01'
                  hint: 'Supported versions: 2025-03-26, 2025-06-18, 2025-11-25. Omit the header to use the server default.'
        '403':
          description: |
            Origin not in MCP allowlist (DNS-rebinding defense).
            Transport-level failure, `MCPTransportError` envelope.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MCPTransportError' }
              example:
                error:
                  code: forbidden_origin
                  message: 'Origin not in MCP allowlist: https://example.com'
                  hint: 'Send requests from longevity.haus, claude.ai, or chatgpt.com — or omit the Origin header for native clients.'
        '413':
          description: |
            Request body exceeds 64 KiB cap. Transport-level failure,
            `MCPTransportError` envelope.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MCPTransportError' }
              example:
                error:
                  code: payload_too_large
                  message: Request body exceeds 65536 bytes.
                  hint: Split bulk operations into multiple JSON-RPC calls, or use pagination cursors on list_treatments / search_clinics.
        '503':
          description: |
            MCP endpoint disabled on this deployment. Transport-level
            failure, `MCPTransportError` envelope.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/MCPTransportError' }
              example:
                error:
                  code: disabled
                  message: MCP endpoint is disabled on this deployment.
                  hint: Retry later or check /status for planned maintenance.
    get:
      tags: [MCP]
      operationId: mcpMethodNotAllowed
      summary: MCP endpoint does not expose a GET surface in v1.
      responses:
        '405':
          description: Method Not Allowed — use POST with a JSON-RPC body.
components:
  headers:
    XRateLimitLimit:
      description: Max requests per window (per IP). Enforced at Cloudflare WAF.
      schema: { type: integer, example: 60 }
    XRateLimitWindow:
      description: Window length in seconds.
      schema: { type: integer, example: 60 }
    CacheControl:
      description: HTTP cache directives; agents SHOULD respect to reduce load.
      schema: { type: string, example: 'public, max-age=3600' }
  responses:
    DatabaseUnavailable:
      description: Convex database client not configured or unreachable.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorEnvelope'
          example:
            error:
              code: database_unavailable
              message: The treatment catalog database is temporarily unreachable.
              hint: Retry after a short backoff; state is usually restored within 30 seconds.
    RateLimited:
      description: Too many requests — try again later.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer, example: 30 }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
          example:
            error:
              code: rate_limited
              message: Rate limit exceeded — 60 requests per minute per IP.
              hint: Wait the seconds in Retry-After, then retry. Consider client-side caching.
  schemas:
    ErrorEnvelope:
      type: object
      description: |
        Uniform error shape for public agent-facing read APIs
        (/api/treatments.json, /api/search-data, /api/exchange-rates,
        /api/health). Agents should branch on `error.code` for retry
        logic and surface `error.message` (+ optional `error.hint`) to
        end users. See `MCPTransportError` for /api/mcp transport-level
        failures; application-level errors inside a JSON-RPC response
        use the `MCPJsonRpcError` shape at /api/mcp's 200 response.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              description: Stable machine-readable error identifier.
              enum:
                - database_unavailable
                - upstream_unavailable
                - invalid_query
                - not_found
                - rate_limited
                - internal_error
              example: database_unavailable
            message:
              type: string
              description: Human-readable message, safe to show end users.
              example: The treatment catalog database is temporarily unreachable.
            hint:
              type: string
              description: Optional next-step suggestion.
              example: Retry after a short backoff; state is usually restored within 30 seconds.
      example:
        error:
          code: database_unavailable
          message: The treatment catalog database is temporarily unreachable.
          hint: Retry after a short backoff; state is usually restored within 30 seconds.
    MCPTransportError:
      type: object
      description: |
        Transport-level error envelope for /api/mcp. Returned when a
        request fails BEFORE JSON-RPC parsing (disabled endpoint,
        forbidden origin, unsupported protocol version, payload too
        large). Application-level failures inside a valid JSON-RPC
        request are returned via the JSON-RPC 2.0 error object on a
        200 response, not via this envelope.
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - disabled
                - forbidden_origin
                - unsupported_protocol
                - payload_too_large
              example: forbidden_origin
            message: { type: string }
            hint: { type: string }
      example:
        error:
          code: forbidden_origin
          message: 'Origin not in MCP allowlist: https://example.com'
          hint: 'Send requests from longevity.haus, claude.ai, or chatgpt.com — or omit the Origin header for native clients.'
    HealthOk:
      type: object
      required: [status, timestamp]
      properties:
        status: { type: string, enum: [ok] }
        message: { type: string }
        timestamp: { type: string, format: date-time }
        database: { type: string, enum: [connected, empty] }
    # HealthError superseded by ErrorEnvelope as of Batch 6 (April 2026).
    # Kept out of the spec to avoid suggesting two error shapes exist.
    TreatmentsCatalog:
      type: object
      required: [generatedAt, categories, biomarkers, serviceStats, meta]
      properties:
        generatedAt:
          type: string
          format: date-time
          description: Server-side generation timestamp (ISO 8601).
        categories:
          type: array
          description: Treatment categories, each with its treatment types.
          items:
            $ref: '#/components/schemas/TreatmentCategory'
        biomarkers:
          type: array
          description: Reference biomarker list (for blood-testing panels).
          items:
            $ref: '#/components/schemas/Biomarker'
        serviceStats:
          type: object
          description: Map of treatment-type slug → summary stats (provider count, price range).
          additionalProperties:
            $ref: '#/components/schemas/ServiceStat'
        meta:
          type: object
          required: [totalBiomarkers, totalCategories, totalServiceTypes]
          properties:
            totalBiomarkers: { type: integer }
            totalCategories: { type: integer }
            totalServiceTypes: { type: integer }
    TreatmentCategory:
      type: object
      required: [slug, name]
      properties:
        slug: { type: string, example: 'diagnostic' }
        name: { type: string, example: 'Diagnostic' }
        description: { type: string }
        treatmentTypes:
          type: array
          items: { $ref: '#/components/schemas/TreatmentType' }
    TreatmentType:
      type: object
      required: [slug, name]
      properties:
        slug: { type: string, example: 'blood-testing' }
        name: { type: string, example: 'Blood Testing' }
        description: { type: string }
    Biomarker:
      type: object
      required: [slug, name]
      properties:
        slug: { type: string }
        name: { type: string }
        category: { type: string, description: 'Biomarker grouping (e.g. lipids, metabolic).' }
    ServiceStat:
      type: object
      properties:
        providerCount: { type: integer }
        locationCount: { type: integer }
        minPrice:
          type: integer
          description: Minimum observed price in the currency's minor units.
        priceCurrency:
          type: string
          description: ISO 4217 code for the minPrice.
    SearchData:
      type: object
      required: [locations, minPricesByCompany, serviceSlugsByCompany, treatmentNames]
      properties:
        locations:
          type: array
          description: All public locations with lat/lng/city/country and provider metadata.
          items: { $ref: '#/components/schemas/LocationSummary' }
        minPricesByCompany:
          type: object
          additionalProperties:
            type: object
            properties:
              price:
                type: integer
                description: Price in minor units.
              currency: { type: string }
              treatmentSlug: { type: string }
        serviceSlugsByCompany:
          type: object
          additionalProperties:
            type: array
            items: { type: string }
        treatmentNames:
          type: object
          description: Map of treatment-type slug → display name.
          additionalProperties: { type: string }
    LocationSummary:
      type: object
      required: [slug, lat, lng]
      properties:
        slug: { type: string }
        companySlug: { type: string }
        companyName: { type: string }
        lat: { type: number }
        lng: { type: number }
        city: { type: string }
        country: { type: string, description: 'ISO 3166-1 alpha-2 country code.' }
        serviceTypes:
          type: array
          items: { type: string }
    ExchangeRates:
      type: object
      required: [base, date, rates]
      properties:
        amount:
          type: number
          description: Base amount the rates describe (always 1).
        base:
          type: string
          description: Base currency (always "EUR").
          example: EUR
        date:
          type: string
          format: date
          description: Rates effective date.
        rates:
          type: object
          description: ISO 4217 code → rate relative to EUR. EUR itself is always 1.
          additionalProperties:
            type: number
