openapi: 3.1.0
# ---------------------------------------------------------------------------
# Share a Wish Public API — OpenAPI 3.1
#
# Source of truth for this file is the Hono app in
#   supabase/functions/public-api/index.ts + routes/*.ts
# Every path documented here is checked against the route registrations by
#   node openapi/validate.mjs
# which also emits openapi/summary.json (consumed by the Partner Portal docs
# and the public developer site). See documentation/api/README.md.
#
# Formatting rule: keep the YAML in the plain subset the validator parses
# (block maps/lists, flow arrays [a, b], quoted strings, "|" block scalars).
# ---------------------------------------------------------------------------
info:
  title: Share a Wish Public API
  version: 1.1.0
  summary: Wishlist, save-widget, hosted lists, creator shops and discovery endpoints of Share a Wish.
  description: |
    The Share a Wish Public API powers the Save Widget (embedded in partner shops),
    the hosted wishlist pages (shareawish.de / shareawish.shop), creator storefronts
    and the consumer app.

    ## Base URL
    All paths below are relative to the server URL. Requests must include a
    Supabase anon `apikey` header when called from outside our own domains
    (the SDK does this for you).

    ## Authentication (three token types)
    * **Public key** — `Authorization: Bearer pk_live_…` / `pk_test_…`. Identifies the partner.
      Used for `POST /widget/init` and `GET /api-usage/{apiKeyId}`. Safe to ship to browsers;
      it only mints short-lived widget tokens for allow-listed origins.
    * **Widget token** — `X-Widget-Token: <jwt>` returned by `/widget/init` (10 min TTL, bound to the
      requesting origin). Required for all `/widget/*` endpoints except `init` and the OAuth bridge.
    * **User token** — `Authorization: Bearer <supabase access token>` of an end user. Required for
      everything that reads or writes a user's wishlists.

    ## Environments
    Keys carry an `environment` of `live` (`pk_live_…`, stored as `prod`) or `test` (`pk_test_…`).
    There is **no separate sandbox**: test keys call the same API and the same database.
    Differences: test keys can be created on every plan (production keys require a plan with
    `has_production_keys`), they show up as `environment: "test"` in `/api-usage`, and when a plan
    is downgraded production keys are converted to test keys after a grace period. Use a test key on
    `localhost` / staging domains; requests with test keys are counted against your quota too.

    ## Rate limits & usage
    `rateLimitMiddleware` (index.ts) runs on `/widget/*`, `/shops/*`, `/hosted/*` and the basket
    routes. It resolves the partner from the public key or widget token, calls
    `check_api_rate_limit`, increments the usage counter and sets the `X-RateLimit-*` /
    `X-Usage-*` headers. **Requests are never rejected with 429** — the headers are informational;
    the technical per-key limits are listed in `x-rate-limit-plans`.

    Billing happens on **wishlist actions** (save, share, hosted-list view) per calendar month,
    not on requests: Free 200 · Starter 2,000 · Growth 15,000 · Scale 60,000 · Enterprise
    individual. Paid plans keep working above the quota and pay per additional action
    (Starter 2.5 ct, Growth 1.0 ct, Scale 0.5 ct — an upgrade is always cheaper than sustained
    overage). The Free plan has a **hard stop**: at 100 % `POST /widget/save` rejects saves with
    `402 usage_limit_reached` until the next calendar month or an upgrade. See
    https://shareawish.shop/developers/rate-limits/

    ## Errors
    Errors are JSON `{ "error": "<snake_case_code>", "message"?: string }` with a 4xx/5xx status.
    Handlers that catch unexpected exceptions return `{ "error": "Internal server error" }` (500).
  contact:
    name: Share a Wish Developer Support
    email: contact@share-a-wish.com
    url: https://shareawish.shop/developers/
  x-sdk:
    canonicalWidgetUrl: https://shareawish.shop/sdk/v1/widget.js
    canonicalBasketUrl: https://shareawish.shop/sdk/v1/basket-integration.js
    legacyUrls:
      - https://shareawish.shop/sdk/save-sdk.js
      - https://shareawish.shop/sdk/basket-integration.js
      - https://cdn.shareawish.shop/sdk/v1/save-sdk.js
      - https://cdn.shareawish.shop/sdk/v1/basket-integration.js
      - https://cdn.shareawish.shop/widget.js
      - https://cdn.shareawish.com/widget.js
    hostedSaveUrl: https://shareawish.shop/save
    hostedBasketUrl: https://shareawish.shop/basket
  x-rate-limit-headers:
    - name: X-RateLimit-Limit
      description: Requests per minute allowed for the plan.
    - name: X-RateLimit-Remaining
      description: Remaining requests in the current minute (never below 0; informational).
    - name: X-RateLimit-Reset
      description: ISO timestamp when the minute window resets.
    - name: X-Usage-Month
      description: Requests counted this calendar month.
    - name: X-Usage-Limit
      description: Included monthly requests of the plan.
    - name: X-Usage-Overage
      description: Overage amount accrued this month (currency units).
  x-rate-limit-plans:
    - plan: Free / trial
      key: trial
      monthlyCalls: 1000
      perMinute: 10
      overagePer1k: 0.05
      productionKeys: false
      webhooks: false
      note: Default for partners without an API subscription (partner.plan = free).
    - plan: Creator (Pro / Business / Enterprise shop plan, no API add-on)
      key: creator
      monthlyCalls: 0
      perMinute: 5
      overagePer1k: 0.10
      productionKeys: true
      webhooks: false
      note: Creator plans include no API calls; every call is overage unless an API plan is added.
    - plan: Premium Creator
      key: premium-creator
      monthlyCalls: 25000
      perMinute: 50
      overagePer1k: 0.01
      productionKeys: true
      webhooks: false
      note: Stackable with an API plan (calls are added on top).
    - plan: Starter API
      key: starter-api
      monthlyCalls: 50000
      perMinute: 100
      overagePer1k: 0.01
      productionKeys: true
      webhooks: false
    - plan: Pro API
      key: pro-api
      monthlyCalls: 500000
      perMinute: 1000
      overagePer1k: 0.008
      productionKeys: true
      webhooks: true
    - plan: Business API
      key: business-api
      monthlyCalls: 2000000
      perMinute: 3000
      overagePer1k: 0.005
      productionKeys: true
      webhooks: true
    - plan: Enterprise API
      key: enterprise-api
      monthlyCalls: 10000000
      perMinute: 5000
      overagePer1k: 0.003
      productionKeys: true
      webhooks: true
  x-rate-limit-source: supabase/migrations/2026-02-05_fix_get_api_plan_limits_security_definer.sql (get_api_plan_limits)

servers:
  - url: https://nqwfhjycwrtukfszuofi.supabase.co/functions/v1/public-api
    description: Production (the only environment; test keys use the same server)

tags:
  - name: Infrastructure
    description: Health and usage endpoints defined in index.ts.
  - name: Widget
    description: Save Widget flow for partner shops (routes/widget.ts). Origin-bound widget token.
  - name: Hosted
    description: Hosted wishlist pages and the consumer app's list CRUD (routes/hosted.ts).
  - name: Shops
    description: Public creator storefront data (routes/shops.ts).
  - name: Discover
    description: Cross-shop discovery, search, trending, seasonal (routes/discover.ts).
  - name: Stores
    description: Retailer catalogue and affiliate URL generation (routes/stores.ts).
  - name: Baskets
    description: Basket widget configuration and events (routes/baskets.ts).
  - name: Analytics
    description: Usage analytics for the Partner Portal (routes/analytics.ts). Internal origins only.
  - name: Me
    description: Consumer account, friends, notifications (routes/me.ts).
  - name: Amazon
    description: Amazon wishlist import/sync (routes/amazon.ts).
  - name: Partners
    description: Partner bootstrap and external storefront listing (routes/partners.ts).
  - name: Roles
    description: Role summary for the current user (routes/roles.ts).
  - name: Images
    description: Image proxy (routes/images.ts).
  - name: OG
    description: Open Graph previews for shared lists (routes/og.ts).
  - name: Sitemap
    description: Dynamic sitemaps (routes/sitemap.ts).
  - name: Blog
    description: Blog content API (routes/blog.ts).
  - name: Feedback
    description: In-app feedback and feature requests (routes/feedback.ts).
  - name: Scraper
    description: Product scraping utilities (routes/scraper_route.ts).
  - name: Media
    description: Creator product videos — public playback (routes/media.ts) and partner management (routes/me-media.ts).
  - name: Usage
    description: Wishlist-action usage of the current billing period for the portal (routes/usage.ts).

security: []

paths:
  # ---------------------------------------------------------------- Infrastructure
  /:
    get:
      tags: [Infrastructure]
      summary: Service status
      operationId: getRoot
      x-audience: partner
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Status'
  /health:
    get:
      tags: [Infrastructure]
      summary: Health check
      operationId: getHealth
      x-audience: partner
      x-llms-priority: 9
      description: Not rate limited. Returns `{ "status": "ok" }`.
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Status'
  /api-usage/{apiKeyId}:
    get:
      tags: [Infrastructure]
      summary: Usage and plan limits for an API key
      operationId: getApiUsage
      x-audience: partner
      x-llms-priority: 5
      description: |
        Authenticate with the public key that owns `apiKeyId`. Returns current minute/hour/day/month
        counters, plan limits (`get_api_plan_limits`) and billing preview. Defined in index.ts;
        routes/keys.ts registers the same handler (index.ts wins).
      security:
        - publicKey: []
      parameters:
        - name: apiKeyId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Usage snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiUsage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Widget
  /widget/init:
    post:
      tags: [Widget]
      summary: Mint a widget token for the current origin
      operationId: widgetInit
      x-audience: partner
      x-llms-priority: 1
      x-rate-limited: true
      description: |
        First call of every SDK session. Validates the public key (`partner_api_key`, status active)
        and checks the request `origin` against the key's allow-list (`partner_api_key_domain`,
        wildcard `*.example.com` supported; `localhost` always allowed). Falls back to the legacy
        `partner.api_public_key` / `partner.allowed_domains` for old keys. Shopify partners with an
        expired trial receive `subscription_required` (403).

        Returns a signed JWT (`X-Widget-Token`, 10 minutes) plus the partner's basket configuration.
        Response headers `X-Usage-Total`, `X-Usage-Limit`, `X-Usage-Remaining` expose the partner's
        wishlist actions of the current calendar month against the plan quota (informational; the
        body carries `overage: true` above the quota). Init itself is never blocked and does not
        count as an action — the Free-plan hard stop applies to `POST /widget/save` only.
      security:
        - publicKey: []
        - {}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [origin]
              properties:
                origin:
                  type: string
                  format: uri
                  description: "`window.location.origin` of the embedding page."
                  examples: ['https://shop.example.com']
                public_key:
                  type: string
                  description: Alternative to the Authorization header.
      responses:
        '200':
          description: Token issued
          headers:
            X-Usage-Total:
              $ref: '#/components/headers/X-Usage-Total'
            X-Usage-Limit:
              $ref: '#/components/headers/X-Usage-Limit'
            X-Usage-Remaining:
              $ref: '#/components/headers/X-Usage-Remaining'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WidgetInitResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          description: "`origin_not_allowed`, `unknown_key` or `subscription_required`"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/ServerError'
  /widget/prefetch:
    post:
      tags: [Widget]
      summary: Canonicalise a product URL and fetch OG metadata
      operationId: widgetPrefetch
      x-audience: partner
      x-rate-limited: true
      security:
        - widgetToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
      responses:
        '200':
          description: Preview data (fields may be empty strings when the page could not be fetched)
          content:
            application/json:
              schema:
                type: object
                properties:
                  canonical_url:
                    type: string
                  title:
                    type: string
                  description:
                    type: string
                  imageUrl:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/ServerError'
  /widget/lists:
    post:
      tags: [Widget]
      summary: Wishlists of the signed-in user (list picker)
      operationId: widgetLists
      x-audience: partner
      x-llms-priority: 4
      x-rate-limited: true
      security:
        - widgetToken: []
          userToken: []
      responses:
        '200':
          description: Lists ordered by creation date desc
          content:
            application/json:
              schema:
                type: object
                properties:
                  lists:
                    type: array
                    items:
                      $ref: '#/components/schemas/WidgetList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /widget/saved-products:
    get:
      tags: [Widget]
      summary: Saved products of the user for this partner (GET)
      operationId: widgetSavedProductsGet
      x-audience: partner
      x-llms-priority: 3
      x-rate-limited: true
      description: |
        Returns the user's saved products filtered to the current partner (`source_partner_id`,
        fallback: product `source_domain` equals the widget origin). If the filter would return an
        empty set while the user has products, all products are returned as a fallback.
      security:
        - widgetToken: []
          userToken: []
      responses:
        '200':
          $ref: '#/components/responses/SavedProducts'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
    post:
      tags: [Widget]
      summary: Saved products of the user for this partner (POST)
      operationId: widgetSavedProductsPost
      x-audience: partner
      x-rate-limited: true
      description: Identical to the GET variant; kept for clients that cannot send custom headers on GET.
      security:
        - widgetToken: []
          userToken: []
      responses:
        '200':
          $ref: '#/components/responses/SavedProducts'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /widget/saved-products/{itemId}:
    delete:
      tags: [Widget]
      summary: Remove a saved product (only items saved from this domain)
      operationId: widgetDeleteSavedProduct
      x-audience: partner
      x-rate-limited: true
      security:
        - widgetToken: []
          userToken: []
      parameters:
        - $ref: '#/components/parameters/ItemId'
      responses:
        '200':
          $ref: '#/components/responses/Success'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /widget/save:
    post:
      tags: [Widget]
      summary: Save a product to a wishlist
      operationId: widgetSave
      x-audience: partner
      x-llms-priority: 2
      x-rate-limited: true
      description: |
        Upserts the product by canonical URL (tracking parameters stripped), stores localized
        title/description/image in `product_i18n`, uploads the image to storage, records a price
        point and links the product to `wishlistId`. Optional `partnerMeta`/`metadata` (variant ids,
        cart params) is written to `product_site_meta` when `siteDomain` matches the widget origin.
        Emits a `save_success` usage event and counts one wishlist action against the partner's
        monthly quota.

        **Quota:** paid plans (Starter / Growth / Scale) are never blocked — actions above the
        quota are billed per action (2.5 / 1.0 / 0.5 ct). On the **Free plan** (200 actions per
        calendar month) the endpoint answers `402 usage_limit_reached` once the quota is used up,
        until the next month or an upgrade. Treat it as a soft failure: show the end customer a
        friendly "wishlist paused" notice and do not retry automatically.
      security:
        - widgetToken: []
          userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SaveRequest'
      responses:
        '200':
          $ref: '#/components/responses/SaveResult'
        '400':
          description: "`missing_wishlist` or `missing_url`"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: "`usage_limit_reached` — Free plan paused at 100 % of its monthly wishlist actions (saving resumes next month or after an upgrade; paid plans never get this)"
          content:
            application/json:
              schema:
                type: object
                required: [error, plan, actions_limit, upgrade_url]
                properties:
                  error:
                    type: string
                    const: usage_limit_reached
                  message:
                    type: string
                  plan:
                    type: string
                    examples: [free]
                  actions_limit:
                    type: integer
                    examples: [200]
                  upgrade_url:
                    type: string
                    format: uri
                    examples: ['https://partner.shareawish.shop/subscription']
        '403':
          description: "`invalid_wishlist` — list does not belong to the user"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: "`product_create_failed` (with `db_error`) or `save_failed`"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /widget/oauth-state:
    post:
      tags: [Widget]
      summary: "OAuth bridge: create a one-time state"
      operationId: widgetOauthState
      x-audience: partner
      x-rate-limited: true
      description: |
        Enables OAuth inside third-party iframes where storage is partitioned. Flow:
        `oauth-state` → open popup with the state → popup calls `oauth-complete` → iframe polls
        `oauth-poll`. State lives 5 minutes in `oauth_bridge_state`. No auth required.
      responses:
        '200':
          description: State created
          content:
            application/json:
              schema:
                type: object
                properties:
                  state:
                    type: string
                    format: uuid
        '500':
          $ref: '#/components/responses/ServerError'
  /widget/oauth-complete:
    post:
      tags: [Widget]
      summary: "OAuth bridge: store session tokens for a state"
      operationId: widgetOauthComplete
      x-audience: partner
      x-rate-limited: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [state, session]
              properties:
                state:
                  type: string
                session:
                  $ref: '#/components/schemas/SessionTokens'
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          description: "`missing_state`, `invalid_session_data` or `invalid_or_expired_state`"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/ServerError'
  /widget/oauth-poll:
    post:
      tags: [Widget]
      summary: "OAuth bridge: poll for the session (one-time read)"
      operationId: widgetOauthPoll
      x-audience: partner
      x-rate-limited: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [state]
              properties:
                state:
                  type: string
      responses:
        '200':
          description: "`status` is `pending`, `complete` (with `session`) or `expired`"
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [pending, complete, expired]
                  session:
                    $ref: '#/components/schemas/SessionTokens'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'
  /widget/session-store:
    post:
      tags: [Widget]
      summary: Persist a user session for the widget session id
      operationId: widgetSessionStore
      x-audience: partner
      x-rate-limited: true
      description: Keyed by the widget token's `sid` + partner. Used by the native basket to restore sessions without cookies.
      security:
        - widgetToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [session]
              properties:
                session:
                  $ref: '#/components/schemas/SessionTokens'
      responses:
        '200':
          description: Stored
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  user_id:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /widget/session-restore:
    post:
      tags: [Widget]
      summary: Restore (and refresh) a stored user session
      operationId: widgetSessionRestore
      x-audience: partner
      x-rate-limited: true
      security:
        - widgetToken: []
      responses:
        '200':
          description: "`status` ok (with `access_token`, `user_id`), `no_session` or `expired`"
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok, no_session, expired]
                  access_token:
                    type: string
                  user_id:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Hosted
  /hosted/lists:
    post:
      tags: [Hosted]
      summary: Wishlists owned by or shared with the user
      operationId: hostedLists
      x-audience: partner
      x-llms-priority: 6
      x-rate-limited: true
      description: Returns owned lists plus lists where the user is an `editor` collaborator, sorted by `updated_at` desc. `id` is the share token.
      security:
        - userToken: []
      responses:
        '200':
          description: Lists
          content:
            application/json:
              schema:
                type: object
                properties:
                  lists:
                    type: array
                    items:
                      $ref: '#/components/schemas/HostedList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/saved-products:
    post:
      tags: [Hosted]
      summary: All saved products of the user (no domain filter)
      operationId: hostedSavedProducts
      x-audience: partner
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Lang'
      responses:
        '200':
          description: Products
          content:
            application/json:
              schema:
                type: object
                properties:
                  products:
                    type: array
                    items:
                      $ref: '#/components/schemas/HostedProduct'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/save:
    post:
      tags: [Hosted]
      summary: Save a product from a hosted page or the app
      operationId: hostedSave
      x-audience: partner
      x-llms-priority: 7
      x-rate-limited: true
      description: |
        Same contract as `/widget/save` but authenticated by user token only. Additionally accepts
        `product.productId` (reuse an existing product), `product.rawProductId` (promote an AWIN feed
        product), `product.sourceLabel`, `product.priority` (1-4) and a user price override.
        Resolves redirects and Amazon paths before canonicalising; triggers a background scrape for
        new or empty products.
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HostedSaveRequest'
      responses:
        '200':
          $ref: '#/components/responses/SaveResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/scrape-preview:
    post:
      tags: [Hosted]
      summary: Scrape product metadata for a URL without saving
      operationId: hostedScrapePreview
      x-audience: partner
      x-rate-limited: true
      description: Cached for 1 hour per URL (unless `forceFresh`). No auth required.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                forceFresh:
                  type: boolean
                externalOnly:
                  type: boolean
      responses:
        '200':
          description: Scraped fields (title, description, image, price, currency, …) plus `_method`, `_cached`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScrapedProduct'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/me/init:
    post:
      tags: [Hosted]
      summary: Ensure the app user row exists; return default wishlist
      operationId: hostedMeInit
      x-audience: partner
      x-rate-limited: true
      description: Creates/backfills `users` (username, profile_url, display_name, locale, market). When a widget token is present, logs an `auth_ok` usage event.
      security:
        - userToken: []
      responses:
        '200':
          description: Initialised
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  user_id:
                    type: string
                  default_wishlist_id:
                    type: [integer, 'null']
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/me/lists:
    post:
      tags: [Hosted]
      summary: Create a wishlist
      operationId: hostedCreateList
      x-audience: partner
      x-rate-limited: true
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                description:
                  type: string
      responses:
        '200':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  list:
                    type: object
                    additionalProperties: true
                  token:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/create-anonymous-user:
    post:
      tags: [Hosted]
      summary: "Migration: create an anonymous auth user for a legacy session"
      operationId: hostedCreateAnonymousUser
      x-audience: internal
      x-rate-limited: true
      description: Used by the PHP migration script only. Body `{ legacyUserId, legacySessionIdentifier }`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          description: Created user and tokens
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/login-legacy-temp-user:
    post:
      tags: [Hosted]
      summary: "Migration: log in a migrated temp user"
      operationId: hostedLoginLegacyTempUser
      x-audience: internal
      x-rate-limited: true
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [legacySessionIdentifier]
              properties:
                legacySessionIdentifier:
                  type: string
      responses:
        '200':
          description: "`ok` with tokens, or `ok: false`"
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/migrate-legacy-maui-user:
    post:
      tags: [Hosted]
      summary: "Migration: mark a legacy MAUI user as migrated (deprecated)"
      operationId: hostedMigrateLegacyMauiUser
      deprecated: true
      x-audience: internal
      x-rate-limited: true
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [legacySessionIdentifier]
              properties:
                legacySessionIdentifier:
                  type: string
      responses:
        '200':
          description: Verified
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/occasions:
    get:
      tags: [Hosted]
      summary: Available occasions with localized titles
      operationId: hostedOccasions
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/Lang'
        - $ref: '#/components/parameters/Market'
      responses:
        '200':
          description: Occasions
          content:
            application/json:
              schema:
                type: object
                properties:
                  occasions:
                    type: array
                    items:
                      type: object
                      properties:
                        slug:
                          type: string
                        title:
                          type: string
                        image_path:
                          type: [string, 'null']
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/products/{productId}:
    delete:
      tags: [Hosted]
      summary: Delete a manual product created by the user
      operationId: hostedDeleteManualProduct
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ProductId'
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/lists/{token}:
    get:
      tags: [Hosted]
      summary: Wishlist metadata by share token (owner or editor)
      operationId: hostedGetList
      x-audience: partner
      x-llms-priority: 8
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Lang'
      responses:
        '200':
          description: List
          content:
            application/json:
              schema:
                type: object
                properties:
                  list:
                    $ref: '#/components/schemas/HostedListDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
    put:
      tags: [Hosted]
      summary: Update list metadata
      operationId: hostedUpdateList
      x-audience: partner
      x-rate-limited: true
      description: Accepts `name` (alias `title`), `description`, `share_public`, `image_path`, `occasion_slug` (validated against `occasion`).
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                title:
                  type: string
                description:
                  type: string
                share_public:
                  type: boolean
                image_path:
                  type: [string, 'null']
                occasion_slug:
                  type: [string, 'null']
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
    delete:
      tags: [Hosted]
      summary: Delete a list and its items (owner only)
      operationId: hostedDeleteList
      x-audience: partner
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/lists/{token}/items:
    get:
      tags: [Hosted]
      summary: Items of a wishlist (owner or editor)
      operationId: hostedListItems
      x-audience: partner
      x-llms-priority: 8
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Lang'
      responses:
        '200':
          description: Products
          content:
            application/json:
              schema:
                type: object
                properties:
                  products:
                    type: array
                    items:
                      $ref: '#/components/schemas/HostedProduct'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/lists/{token}/items/manual:
    post:
      tags: [Hosted]
      summary: Create a manual item (no product URL)
      operationId: hostedCreateManualItem
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/Market'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                description:
                  type: string
                image_path:
                  type: string
                price_minor:
                  type: integer
                price_currency:
                  type: string
                source_label:
                  type: string
                priority:
                  type: integer
                  minimum: 1
                  maximum: 4
      responses:
        '200':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  item_id:
                    type: integer
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/lists/{token}/items/{itemId}:
    put:
      tags: [Hosted]
      summary: Update an item (overrides and purchased flag)
      operationId: hostedUpdateItem
      x-audience: partner
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/ItemId'
        - $ref: '#/components/parameters/Market'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ItemOverridePatch'
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
    delete:
      tags: [Hosted]
      summary: Remove an item (and garbage-collect manual products)
      operationId: hostedDeleteItem
      x-audience: partner
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/ItemId'
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/lists/{token}/items/{itemId}/move:
    post:
      tags: [Hosted]
      summary: Move or copy an item to another list
      operationId: hostedMoveItem
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/ItemId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [targetToken]
              properties:
                targetToken:
                  type: string
                mode:
                  type: string
                  enum: [move, copy]
                  default: move
      responses:
        '200':
          description: Done
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  mode:
                    type: string
                  item_id:
                    type: integer
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/lists/{token}/items/{itemId}/rescrape:
    post:
      tags: [Hosted]
      summary: Force a re-scrape of the item's product
      operationId: hostedRescrapeItem
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/ItemId'
      responses:
        '200':
          description: Fresh product data
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  product_id:
                    type: integer
                  data:
                    $ref: '#/components/schemas/ScrapedProduct'
                  method:
                    type: [string, 'null']
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '502':
          description: Scrape failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/lists/{token}/items/{itemId}/purchased:
    put:
      tags: [Hosted]
      summary: Toggle the purchased flag
      operationId: hostedSetPurchased
      x-audience: partner
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/ItemId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PurchasedBody'
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/lists/{token}/collaborators:
    get:
      tags: [Hosted]
      summary: List collaborators (owner only)
      operationId: hostedCollaborators
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
      responses:
        '200':
          description: Collaborators
          content:
            application/json:
              schema:
                type: object
                properties:
                  collaborators:
                    type: array
                    items:
                      type: object
                      properties:
                        user_id:
                          type: string
                        role:
                          type: string
                          enum: [editor, viewer]
                        name:
                          type: string
                        avatar:
                          type: [string, 'null']
                        created_at:
                          type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
    post:
      tags: [Hosted]
      summary: Invite a friend as collaborator (owner only)
      operationId: hostedAddCollaborator
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_id]
              properties:
                user_id:
                  type: string
                role:
                  type: string
                  enum: [editor, viewer]
                  default: editor
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: "`forbidden` (not owner) or `not_a_friend`"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/lists/{token}/collaborators/{userId}:
    delete:
      tags: [Hosted]
      summary: Remove a collaborator (owner only)
      operationId: hostedRemoveCollaborator
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - name: userId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/public/lists/{token}:
    get:
      tags: [Hosted]
      summary: Public wishlist metadata (share page)
      operationId: hostedPublicList
      x-audience: partner
      x-rate-limited: true
      description: Only lists with `share_public = true`. No auth. Includes owner display name/avatar.
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Lang'
      responses:
        '200':
          description: List
          content:
            application/json:
              schema:
                type: object
                properties:
                  list:
                    $ref: '#/components/schemas/HostedListDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/public/lists/{token}/items:
    get:
      tags: [Hosted]
      summary: Items of a public wishlist
      operationId: hostedPublicListItems
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Lang'
      responses:
        '200':
          description: Products (with `purchased`, `added_at`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  products:
                    type: array
                    items:
                      $ref: '#/components/schemas/HostedProduct'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/public/lists/{token}/items/{itemId}/purchased:
    put:
      tags: [Hosted]
      summary: Mark an item of a public list as purchased (no auth)
      operationId: hostedPublicSetPurchased
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/ItemId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PurchasedBody'
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  purchased:
                    type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/public/lists/{token}/link:
    post:
      tags: [Hosted]
      summary: Follow a public list (viewer role) and auto-friend the owner
      operationId: hostedLinkPublicList
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
      responses:
        '200':
          description: Linked
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  already:
                    type: boolean
                  wishlist_token:
                    type: string
                  auto_friend:
                    type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
    delete:
      tags: [Hosted]
      summary: Unfollow a public list
      operationId: hostedUnlinkPublicList
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
      responses:
        '200':
          description: Unlinked
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  removed:
                    type: boolean
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /hosted/migrate-anonymous-to-provider:
    post:
      tags: [Hosted]
      summary: Transfer anonymous user data to a provider-authenticated user
      operationId: hostedMigrateAnonymousToProvider
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [old_user_id, new_user_id]
              properties:
                old_user_id:
                  type: string
                new_user_id:
                  type: string
      responses:
        '200':
          description: Migrated
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Shops
  /shops/{handle}:
    get:
      tags: [Shops]
      summary: Public shop overview
      operationId: shopGet
      x-audience: partner
      x-llms-priority: 10
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/ShopHandle'
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/PreviewToken'
      responses:
        '200':
          description: Shop
          content:
            application/json:
              schema:
                type: object
                properties:
                  shop:
                    $ref: '#/components/schemas/ShopPublic'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/by-domain/{domain}:
    get:
      tags: [Shops]
      summary: Resolve a shop by custom domain
      operationId: shopByDomain
      x-audience: partner
      x-rate-limited: true
      parameters:
        - name: domain
          in: path
          required: true
          schema:
            type: string
        - $ref: '#/components/parameters/Market'
      responses:
        '200':
          description: Shop
          content:
            application/json:
              schema:
                type: object
                properties:
                  shop:
                    $ref: '#/components/schemas/ShopPublic'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/{handle}/lists:
    get:
      tags: [Shops]
      summary: Curated lists of a shop
      operationId: shopLists
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/ShopHandle'
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/PreviewToken'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Lists plus minimal shop info
          content:
            application/json:
              schema:
                type: object
                properties:
                  lists:
                    type: array
                    items:
                      $ref: '#/components/schemas/ShopList'
                  shop:
                    type: [object, 'null']
                    additionalProperties: true
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/{handle}/categories:
    get:
      tags: [Shops]
      summary: Categories of a shop with product counts
      operationId: shopCategories
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/ShopHandle'
        - $ref: '#/components/parameters/PreviewToken'
      responses:
        '200':
          description: Categories (empty array when the shop is unknown)
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/{handle}/products:
    get:
      tags: [Shops]
      summary: Products of a shop (optionally by category)
      operationId: shopProducts
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/ShopHandle'
        - name: category
          in: query
          schema:
            type: string
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/PreviewToken'
      responses:
        '200':
          $ref: '#/components/responses/ShopProducts'
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/{handle}/search:
    get:
      tags: [Shops]
      summary: Search products and lists inside a shop
      operationId: shopSearch
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/ShopHandle'
        - name: query
          in: query
          required: true
          schema:
            type: string
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/PreviewToken'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Results (`Cache-Control: public, max-age=60`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  products:
                    type: array
                    items:
                      $ref: '#/components/schemas/ShopProduct'
                  lists:
                    type: array
                    items:
                      $ref: '#/components/schemas/ShopList'
                  total:
                    type: integer
                  hasMore:
                    type: boolean
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/{handle}/lists/{slug}/products:
    get:
      tags: [Shops]
      summary: Products of a curated list
      operationId: shopListProducts
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/ShopHandle'
        - name: slug
          in: path
          required: true
          schema:
            type: string
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/PreviewToken'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Products with total
          content:
            application/json:
              schema:
                type: object
                properties:
                  products:
                    type: array
                    items:
                      $ref: '#/components/schemas/ShopProduct'
                  total:
                    type: integer
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/{handle}/featured-lists:
    get:
      tags: [Shops]
      summary: Featured lists of a shop
      operationId: shopFeaturedLists
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/ShopHandle'
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/PreviewToken'
      responses:
        '200':
          description: Lists
          content:
            application/json:
              schema:
                type: object
                properties:
                  lists:
                    type: array
                    items:
                      $ref: '#/components/schemas/ShopList'
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/{handle}/trending:
    get:
      tags: [Shops]
      summary: Trending products of a shop
      operationId: shopTrending
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/ShopHandle'
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/PreviewToken'
        - name: limit
          in: query
          schema:
            type: integer
            default: 8
            maximum: 50
      responses:
        '200':
          description: Products with the scoring parameters used
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TrendingProducts'
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/{handle}/analytics/followers:
    get:
      tags: [Shops]
      summary: Follower time series of a public shop
      operationId: shopFollowerSeries
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/ShopHandle'
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
      responses:
        '200':
          $ref: '#/components/responses/Series'
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/@me/analytics/followers:
    get:
      tags: [Shops]
      summary: Follower time series across the caller's shops
      operationId: myShopsFollowerSeries
      x-audience: partner
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/DateFrom'
        - $ref: '#/components/parameters/DateTo'
      responses:
        '200':
          $ref: '#/components/responses/Series'
        '401':
          description: Unauthenticated (returns `{ series: [] }`)
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/{handle}/follow:
    post:
      tags: [Shops]
      summary: Follow a shop
      operationId: shopFollow
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ShopHandle'
      responses:
        '200':
          $ref: '#/components/responses/FollowState'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
    delete:
      tags: [Shops]
      summary: Unfollow a shop
      operationId: shopUnfollow
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ShopHandle'
      responses:
        '200':
          $ref: '#/components/responses/FollowState'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /shops/@me/following:
    get:
      tags: [Shops]
      summary: Shops the user follows
      operationId: myFollowedShops
      x-audience: consumer
      x-rate-limited: true
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Shops
          content:
            application/json:
              schema:
                type: object
                properties:
                  shops:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Discover
  /discover/trending:
    get:
      tags: [Discover]
      summary: Platform-wide trending products
      operationId: discoverTrending
      x-audience: partner
      parameters:
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/WindowDays'
      responses:
        '200':
          description: Products with `source`, `windowDays`, `weights`, `scope`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TrendingProducts'
        '500':
          $ref: '#/components/responses/ServerError'
  /discover/search:
    get:
      tags: [Discover]
      summary: Unified search (lists or products)
      operationId: discoverSearch
      x-audience: partner
      description: "`scope=lists` returns curated lists across public shops; `scope=products` returns Amazon + AWIN feed products, optionally augmented with live Amazon results (`live=1`)."
      parameters:
        - name: scope
          in: query
          schema:
            type: string
            enum: [lists, products]
            default: lists
        - name: query
          in: query
          schema:
            type: string
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - name: live
          in: query
          schema:
            type: string
      responses:
        '200':
          description: Either `{ lists }` or `{ products }`
          content:
            application/json:
              schema:
                type: object
                properties:
                  lists:
                    type: array
                    items:
                      $ref: '#/components/schemas/DiscoverList'
                  products:
                    type: array
                    items:
                      $ref: '#/components/schemas/ShopProduct'
        '500':
          $ref: '#/components/responses/ServerError'
  /discover/shops:
    get:
      tags: [Discover]
      summary: Search partner shops
      operationId: discoverShops
      x-audience: partner
      parameters:
        - name: query
          in: query
          schema:
            type: string
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Shops
          content:
            application/json:
              schema:
                type: object
                properties:
                  shops:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
        '500':
          $ref: '#/components/responses/ServerError'
  /discover/partners:
    get:
      tags: [Discover]
      summary: Public creator shops
      operationId: discoverPartners
      x-audience: partner
      parameters:
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Sort'
        - $ref: '#/components/parameters/WindowDays'
      responses:
        '200':
          description: Partners
          content:
            application/json:
              schema:
                type: object
                properties:
                  partners:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
        '500':
          $ref: '#/components/responses/ServerError'
  /discover/curated-lists:
    get:
      tags: [Discover]
      summary: Featured lists across public shops
      operationId: discoverCuratedLists
      x-audience: partner
      parameters:
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Sort'
      responses:
        '200':
          description: Lists
          content:
            application/json:
              schema:
                type: object
                properties:
                  lists:
                    type: array
                    items:
                      $ref: '#/components/schemas/DiscoverList'
        '500':
          $ref: '#/components/responses/ServerError'
  /discover/products/{productId}:
    get:
      tags: [Discover]
      summary: Product detail
      operationId: discoverProduct
      x-audience: partner
      parameters:
        - $ref: '#/components/parameters/ProductId'
        - $ref: '#/components/parameters/Market'
      responses:
        '200':
          description: Product (`Cache-Control: public, max-age=120`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  product:
                    $ref: '#/components/schemas/ShopProduct'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /discover/products/{productId}/price-history:
    get:
      tags: [Discover]
      summary: Price history (scraper + offer prices, one point per day)
      operationId: discoverPriceHistory
      x-audience: partner
      parameters:
        - $ref: '#/components/parameters/ProductId'
      responses:
        '200':
          description: History (`Cache-Control: public, max-age=300`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  productId:
                    type: integer
                  history:
                    type: array
                    items:
                      $ref: '#/components/schemas/PricePoint'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'
  /discover/seasonal:
    get:
      tags: [Discover]
      summary: Seasonal products
      operationId: discoverSeasonal
      x-audience: partner
      parameters:
        - name: season
          in: query
          required: true
          description: Slug or localized title, e.g. `winter`
          schema:
            type: string
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/WindowDays'
      responses:
        '200':
          description: Products
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TrendingProducts'
        '500':
          $ref: '#/components/responses/ServerError'
  /discover/occasions:
    get:
      tags: [Discover]
      summary: Products for an occasion
      operationId: discoverOccasions
      x-audience: partner
      parameters:
        - name: occasion
          in: query
          required: true
          description: Slug or localized title, e.g. `weihnachten`
          schema:
            type: string
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/WindowDays'
      responses:
        '200':
          description: Products
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TrendingProducts'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Stores
  /stores:
    get:
      tags: [Stores]
      summary: Retailers available in the in-app browser
      operationId: storesList
      x-audience: partner
      parameters:
        - name: category
          in: query
          schema:
            type: string
        - $ref: '#/components/parameters/Country'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/IncludeAdult'
      responses:
        '200':
          description: Stores with pagination
          content:
            application/json:
              schema:
                type: object
                properties:
                  stores:
                    type: array
                    items:
                      $ref: '#/components/schemas/Store'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '500':
          $ref: '#/components/responses/ServerError'
  /stores/categories:
    get:
      tags: [Stores]
      summary: Store categories with counts
      operationId: storesCategories
      x-audience: partner
      parameters:
        - $ref: '#/components/parameters/Country'
        - $ref: '#/components/parameters/IncludeAdult'
      responses:
        '200':
          description: Categories
          content:
            application/json:
              schema:
                type: object
                properties:
                  categories:
                    type: array
                    items:
                      type: object
                      properties:
                        name:
                          type: string
                        count:
                          type: integer
        '500':
          $ref: '#/components/responses/ServerError'
  /stores/by-category:
    get:
      tags: [Stores]
      summary: Stores grouped by category
      operationId: storesByCategory
      x-audience: partner
      parameters:
        - $ref: '#/components/parameters/Country'
        - name: limitPerCategory
          in: query
          schema:
            type: integer
            default: 6
            maximum: 20
        - $ref: '#/components/parameters/IncludeAdult'
      responses:
        '200':
          description: Map of category → stores
          content:
            application/json:
              schema:
                type: object
                properties:
                  storesByCategory:
                    type: object
                    additionalProperties:
                      type: array
                      items:
                        $ref: '#/components/schemas/Store'
        '500':
          $ref: '#/components/responses/ServerError'
  /stores/affiliate-info:
    post:
      tags: [Stores]
      summary: Affiliate program information for a URL
      operationId: storesAffiliateInfo
      x-audience: partner
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
      responses:
        '200':
          description: Match result (Amazon URLs are handled by the legacy path)
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                properties:
                  found:
                    type: boolean
                  storeId:
                    type: [integer, 'null']
                  storeName:
                    type: [string, 'null']
                  networkName:
                    type: [string, 'null']
                  hasAffiliate:
                    type: boolean
                  deeplinkTemplate:
                    type: [string, 'null']
                  isLegacyAmazon:
                    type: boolean
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'
  /stores/generate-affiliate-url:
    post:
      tags: [Stores]
      summary: Generate an affiliate URL for a store link
      operationId: storesGenerateAffiliateUrl
      x-audience: partner
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                storeId:
                  type: integer
                context:
                  type: object
                  additionalProperties: true
                  description: "`{ wishlistItemId?, listId?, source? }` for tracking sub-ids"
      responses:
        '200':
          description: Result; `method` is `amazon_legacy`, `store_mapping` or `none`
          content:
            application/json:
              schema:
                type: object
                properties:
                  originalUrl:
                    type: string
                  affiliateUrl:
                    type: string
                  networkName:
                    type: [string, 'null']
                  method:
                    type: string
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'
  /stores/{slug}:
    get:
      tags: [Stores]
      summary: Store detail
      operationId: storesGet
      x-audience: partner
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Store
          content:
            application/json:
              schema:
                type: object
                properties:
                  store:
                    $ref: '#/components/schemas/Store'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Baskets
  /baskets/{id}:
    get:
      tags: [Baskets]
      summary: Basket widget configuration by public id
      operationId: basketGet
      x-audience: partner
      x-rate-limited: true
      description: |
        Used by the Basket SDK. No auth. Note: index.ts registers the rate limiter on
        `/basket/*` while the router is mounted at `/baskets` — until that prefix is aligned the
        middleware does not actually run for these routes.
      parameters:
        - $ref: '#/components/parameters/BasketId'
      responses:
        '200':
          description: Configuration
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BasketConfig'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /baskets/{id}/event:
    post:
      tags: [Baskets]
      summary: Track a basket open/checkout event
      operationId: basketEvent
      x-audience: partner
      x-rate-limited: true
      parameters:
        - $ref: '#/components/parameters/BasketId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                type:
                  type: string
                  enum: [open, checkout]
                  default: open
                detail:
                  type: object
                  additionalProperties: true
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Analytics
  /analytics/usage:
    post:
      tags: [Analytics]
      summary: Usage statistics for a partner / API key
      operationId: analyticsUsage
      x-audience: internal
      description: Only accepted from Share a Wish origins (`isInternalOrigin`). Partner is resolved from `apiKeyId`, `partnerId` or the user token. Not rate limited.
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnalyticsQuery'
      responses:
        '200':
          description: Totals, time series, top origins, funnel
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /analytics/debug:
    post:
      tags: [Analytics]
      summary: Raw `usage_event_stats` output for debugging
      operationId: analyticsDebug
      x-audience: internal
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AnalyticsQuery'
      responses:
        '200':
          description: Debug payload
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/ServerError'
  /analytics/track:
    post:
      tags: [Analytics]
      summary: Track a UI event
      operationId: analyticsTrack
      x-audience: internal
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [partnerId, event]
              properties:
                partnerId:
                  type: integer
                event:
                  type: string
                origin:
                  type: string
                context:
                  type: object
                  additionalProperties: true
                status:
                  type: integer
      responses:
        '200':
          $ref: '#/components/responses/Ok'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Me (consumer app)
  /me/init:
    post:
      tags: [Me]
      summary: Ensure app user and profile fields
      operationId: meInit
      x-audience: consumer
      security:
        - userToken: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/user:
    delete:
      tags: [Me]
      summary: Delete the current user account
      operationId: meDeleteUser
      x-audience: consumer
      security:
        - userToken: []
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/lists:
    post:
      tags: [Me]
      summary: Create a wishlist (app)
      operationId: meCreateList
      x-audience: consumer
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                description:
                  type: string
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/friends/wishlists:
    get:
      tags: [Me]
      summary: Wishlists shared with me or public lists of friends
      operationId: meFriendsWishlists
      x-audience: consumer
      security:
        - userToken: []
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/friends/wishlists/{token}:
    get:
      tags: [Me]
      summary: A friend's wishlist with owner metadata
      operationId: meFriendWishlist
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/friends/wishlists/{token}/items:
    get:
      tags: [Me]
      summary: Items of a friend's wishlist
      operationId: meFriendWishlistItems
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/friends/wishlists/{token}/items/{itemId}/purchased:
    put:
      tags: [Me]
      summary: Mark a friend's item as purchased
      operationId: meFriendItemPurchased
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/ItemId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PurchasedBody'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/friends/{friendId}/profile:
    get:
      tags: [Me]
      summary: Friend profile with shared/public lists
      operationId: meFriendProfile
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/FriendId'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/friends/{friendId}:
    delete:
      tags: [Me]
      summary: Unfriend
      operationId: meUnfriend
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/FriendId'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/notifications:
    get:
      tags: [Me]
      summary: In-app notifications
      operationId: meNotifications
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/notifications/{id}/read:
    post:
      tags: [Me]
      summary: Mark a notification as read
      operationId: meNotificationRead
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/notifications/read-all:
    post:
      tags: [Me]
      summary: Mark all notifications as read
      operationId: meNotificationsReadAll
      x-audience: consumer
      security:
        - userToken: []
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/push-token:
    post:
      tags: [Me]
      summary: Register a push token
      operationId: mePushTokenRegister
      x-audience: consumer
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, platform]
              properties:
                token:
                  type: string
                platform:
                  type: string
                  enum: [ios, android, web]
                deviceId:
                  type: string
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
    delete:
      tags: [Me]
      summary: Unregister a push token
      operationId: mePushTokenUnregister
      x-audience: consumer
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/notification-settings:
    get:
      tags: [Me]
      summary: Push notification settings
      operationId: meNotificationSettingsGet
      x-audience: consumer
      security:
        - userToken: []
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
    patch:
      tags: [Me]
      summary: Update push notification settings
      operationId: meNotificationSettingsPatch
      x-audience: consumer
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                eventReminders:
                  type: boolean
                birthdayReminders:
                  type: boolean
                friendActivity:
                  type: boolean
                priceAlerts:
                  type: boolean
                birthday:
                  type: [string, 'null']
                  description: YYYY-MM-DD
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/invite-code:
    get:
      tags: [Me]
      summary: My invite code
      operationId: meInviteCode
      x-audience: consumer
      security:
        - userToken: []
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/invite-info/{code}:
    get:
      tags: [Me]
      summary: Public inviter info for an invite code
      operationId: meInviteInfo
      x-audience: consumer
      parameters:
        - name: code
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/accept-invite:
    post:
      tags: [Me]
      summary: Accept an invite (creates the friend connection)
      operationId: meAcceptInvite
      x-audience: consumer
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [invite_code]
              properties:
                invite_code:
                  type: string
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/users/search:
    get:
      tags: [Me]
      summary: Search users by username or exact e-mail
      operationId: meUsersSearch
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/friend-requests:
    get:
      tags: [Me]
      summary: Pending friend requests (incoming/outgoing)
      operationId: meFriendRequests
      x-audience: consumer
      security:
        - userToken: []
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
    post:
      tags: [Me]
      summary: Send a friend request
      operationId: meFriendRequestCreate
      x-audience: consumer
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [recipient_id]
              properties:
                recipient_id:
                  type: string
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/friend-requests/{id}/accept:
    post:
      tags: [Me]
      summary: Accept a friend request
      operationId: meFriendRequestAccept
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/friend-requests/{id}/decline:
    post:
      tags: [Me]
      summary: Decline a friend request
      operationId: meFriendRequestDecline
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/friend-requests/{id}:
    delete:
      tags: [Me]
      summary: Cancel an outgoing friend request
      operationId: meFriendRequestCancel
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/shared-wishlists/{wishlistId}:
    delete:
      tags: [Me]
      summary: Leave a wishlist that was shared with me
      operationId: meLeaveSharedWishlist
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - name: wishlistId
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Media
  /products/{id}/media:
    get:
      tags: [Media]
      summary: Ready creator videos for a product
      operationId: productMedia
      x-audience: partner
      x-rate-limited: true
      description: Videos are visible only when the creator has a published, public shop that lists the product. Capped at 20; includes the creator (shop) identity for attribution.
      parameters:
        - $ref: '#/components/parameters/Id'
        - $ref: '#/components/parameters/Market'
      responses:
        '200':
          description: Media list
          content:
            application/json:
              schema:
                type: object
                properties:
                  product_id:
                    type: integer
                  media:
                    type: array
                    items:
                      $ref: '#/components/schemas/Media'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/media:
    get:
      tags: [Media]
      summary: My product videos (with plan limit info)
      operationId: myMediaList
      x-audience: partner
      security:
        - userToken: []
      parameters:
        - name: product_id
          in: query
          schema:
            type: integer
        - name: shop_id
          in: query
          schema:
            type: integer
        - name: partner_id
          in: query
          schema:
            type: integer
      responses:
        '200':
          description: Media rows plus `limit` (`{ used, max }`)
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                properties:
                  media:
                    type: array
                    items:
                      $ref: '#/components/schemas/Media'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/ServerError'
    post:
      tags: [Media]
      summary: Register an uploaded video (status ready)
      operationId: myMediaCreate
      x-audience: partner
      description: Call after uploading to the signed URL from `/me/media/upload-url`. Returns 402 `plan_limit_reached` when the plan's video quota is used up, 409 `object_not_found` when the storage object is missing.
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MediaCreate'
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                properties:
                  media:
                    $ref: '#/components/schemas/Media'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: "`plan_limit_reached`"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: "`object_not_found`"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: "`file_too_large`"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/media/upload-url:
    post:
      tags: [Media]
      summary: Signed upload URLs for a video (and poster)
      operationId: myMediaUploadUrl
      x-audience: partner
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [product_id, filename, mime, bytes]
              properties:
                product_id:
                  type: integer
                shop_id:
                  type: integer
                partner_id:
                  type: integer
                filename:
                  type: string
                mime:
                  type: string
                  description: One of the allowed video MIME types (`unsupported_mime` otherwise)
                bytes:
                  type: integer
      responses:
        '200':
          description: Signed upload URL(s) and storage paths
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: "`plan_limit_reached`"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          description: "`file_too_large`"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/ServerError'
  /me/media/{id}:
    patch:
      tags: [Media]
      summary: Update title, caption, language, position or status of a video
      operationId: myMediaUpdate
      x-audience: partner
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: [string, 'null']
                caption:
                  type: [string, 'null']
                language:
                  type: [string, 'null']
                position:
                  type: integer
                status:
                  type: string
                  enum: [hidden, ready]
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  media:
                    $ref: '#/components/schemas/Media'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: "`plan_limit_reached` when un-hiding above the quota"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: "`media_rejected`"
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/ServerError'
    delete:
      tags: [Media]
      summary: Delete a video and its storage objects
      operationId: myMediaDelete
      x-audience: partner
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  id:
                    type: integer
                  removed_objects:
                    type: integer
                  url:
                    type: [string, 'null']
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Usage
  /me/usage:
    get:
      tags: [Usage]
      summary: Wishlist-action usage of the current period (portal)
      operationId: myUsage
      x-audience: internal
      description: |
        Resolves the partner behind the authenticated portal user and returns used/limit actions of
        the billing period, the effective plan, a warning level and the overage fields of the
        Pricing-2026 model (`hard_limit`, `overage_per_action_minor`, `overage_actions`,
        `overage_cost_minor`, `next_plan`, `upgrade_cheaper_from_actions`, `blocked`).
      security:
        - userToken: []
      responses:
        '200':
          description: Usage
          content:
            application/json:
              schema:
                type: object
                properties:
                  plan:
                    type: string
                  plan_name:
                    type: string
                  actions_used:
                    type: integer
                  actions_limit:
                    type: [integer, 'null']
                  period_start:
                    type: string
                  period_end:
                    type: string
                  breakdown:
                    type: object
                    additionalProperties: true
                  percent:
                    type: number
                  level:
                    type: string
                  trial_days:
                    type: integer
                  hard_limit:
                    type: boolean
                    description: true on Free — saves are rejected (402) once the limit is reached.
                  overage_per_action_minor:
                    type: [number, 'null']
                    description: Overage price per extra action in cents (2.5 / 1 / 0.5); null on Free and Enterprise.
                  overage_actions:
                    type: integer
                    description: max(0, actions_used - actions_limit).
                  overage_cost_minor:
                    type: integer
                    description: overage_actions * overage_per_action_minor, rounded (cents).
                  next_plan:
                    type: [object, 'null']
                    properties:
                      id:
                        type: string
                      name:
                        type: string
                      price_monthly:
                        type: number
                      actions:
                        type: [integer, 'null']
                  upgrade_cheaper_from_actions:
                    type: [integer, 'null']
                    description: Actions over the limit from which next_plan is cheaper than paying overage (Starter→Growth 1,200, Growth→Scale 5,000).
                  blocked:
                    type: boolean
                    description: hard_limit && actions_used >= actions_limit — the widget currently rejects saves.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Amazon
  /amazon/connect:
    post:
      tags: [Amazon]
      summary: Connect a public Amazon wishlist to a wishlist
      operationId: amazonConnect
      x-audience: consumer
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [listUrl]
              properties:
                listUrl:
                  type: string
                  format: uri
                targetWishlistId:
                  type: integer
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/ServerError'
  /amazon/lists:
    get:
      tags: [Amazon]
      summary: Connected Amazon lists
      operationId: amazonLists
      x-audience: consumer
      security:
        - userToken: []
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /amazon/toggle-track:
    post:
      tags: [Amazon]
      summary: Toggle tracking of a connected list
      operationId: amazonToggleTrack
      x-audience: consumer
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /amazon/sync:
    post:
      tags: [Amazon]
      summary: Sync a connected Amazon list now
      operationId: amazonSync
      x-audience: consumer
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /amazon/test-delete:
    get:
      tags: [Amazon]
      summary: Test route (returns a static payload)
      operationId: amazonTestDelete
      x-audience: internal
      responses:
        '200':
          $ref: '#/components/responses/Loose'
  /amazon/list/{id}:
    delete:
      tags: [Amazon]
      summary: Remove a connected Amazon list
      operationId: amazonDeleteList
      x-audience: consumer
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /amazon/public/sync/{token}:
    get:
      tags: [Amazon]
      summary: Trigger a sync for a public wishlist (cool-down protected)
      operationId: amazonPublicSync
      x-audience: consumer
      parameters:
        - $ref: '#/components/parameters/ListToken'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Partners
  /partners/init:
    post:
      tags: [Partners]
      summary: Create a partner for the current user (owner role)
      operationId: partnersInit
      x-audience: internal
      security:
        - userToken: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'
  /partners/{id}:
    delete:
      tags: [Partners]
      summary: Soft-delete a partner (owner only)
      operationId: partnersDelete
      x-audience: internal
      security:
        - userToken: []
      parameters:
        - $ref: '#/components/parameters/Id'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/ServerError'
  /partners/storefront/me:
    get:
      tags: [Partners]
      summary: My external storefront listing
      operationId: partnersStorefrontGet
      x-audience: partner
      security:
        - userToken: []
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
    put:
      tags: [Partners]
      summary: Create or update my external storefront listing
      operationId: partnersStorefrontPut
      x-audience: partner
      security:
        - userToken: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [shop_name, shop_url]
              properties:
                shop_name:
                  type: string
                shop_url:
                  type: string
                icon_url:
                  type: string
                banner_url:
                  type: string
                description:
                  type: string
                is_visible:
                  type: boolean
                allowed_markets:
                  type: array
                  items:
                    type: string
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'
  /partners/storefronts:
    get:
      tags: [Partners]
      summary: Public listing of external partner storefronts
      operationId: partnersStorefronts
      x-audience: partner
      parameters:
        - $ref: '#/components/parameters/Market'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Roles
  /roles/summary:
    post:
      tags: [Roles]
      summary: Whether the user has app-user and/or partner roles
      operationId: rolesSummary
      x-audience: internal
      security:
        - userToken: []
      responses:
        '200':
          description: Summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  hasUser:
                    type: boolean
                  hasPartner:
                    type: boolean
                  partnerIds:
                    type: array
                    items:
                      type: integer
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Images
  /images/proxy:
    get:
      tags: [Images]
      summary: Image proxy / resizer for allow-listed hosts
      operationId: imagesProxy
      x-audience: partner
      description: Supabase storage URLs are transformed through the render endpoint; other allow-listed CDNs are passed through. Response is the image bytes with `Cache-Control: public, max-age=86400`.
      parameters:
        - name: url
          in: query
          schema:
            type: string
            format: uri
        - name: url_b64
          in: query
          description: Base64-encoded alternative to `url`
          schema:
            type: string
        - name: width
          in: query
          schema:
            type: integer
        - name: height
          in: query
          schema:
            type: integer
        - name: quality
          in: query
          schema:
            type: integer
            default: 75
        - name: format
          in: query
          schema:
            type: string
            default: webp
        - name: resize
          in: query
          schema:
            type: string
            default: cover
      responses:
        '200':
          description: Image
          content:
            image/*:
              schema:
                type: string
                format: binary
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- OG
  /og/list/{token}:
    get:
      tags: [OG]
      summary: HTML link preview for a shared list (bots)
      operationId: ogList
      x-audience: partner
      parameters:
        - $ref: '#/components/parameters/ListToken'
        - $ref: '#/components/parameters/Lang'
      responses:
        '200':
          description: HTML document with Open Graph tags
          content:
            text/html:
              schema:
                type: string
  /og/image/{file}:
    get:
      tags: [OG]
      summary: 1200×630 Open Graph JPEG for a list (`<token>.jpg`)
      operationId: ogImage
      x-audience: partner
      parameters:
        - name: file
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: JPEG (falls back to a default image)
          content:
            image/jpeg:
              schema:
                type: string
                format: binary

  # ---------------------------------------------------------------- Sitemap
  /sitemap.xml:
    get:
      tags: [Sitemap]
      summary: Sitemap of indexable creator shops (24 h cache)
      operationId: sitemapShops
      x-audience: internal
      responses:
        '200':
          description: XML
          content:
            application/xml:
              schema:
                type: string
        '500':
          $ref: '#/components/responses/ServerError'
  /sitemap/refresh:
    get:
      tags: [Sitemap]
      summary: Clear the shop sitemap cache
      operationId: sitemapRefresh
      x-audience: internal
      responses:
        '200':
          $ref: '#/components/responses/Loose'
  /sitemap/stats:
    get:
      tags: [Sitemap]
      summary: Indexable content statistics
      operationId: sitemapStats
      x-audience: internal
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '500':
          $ref: '#/components/responses/ServerError'
  /sitemap-user.xml:
    get:
      tags: [Sitemap]
      summary: Sitemap of public user wishlists (shareawish.de)
      operationId: sitemapUsers
      x-audience: internal
      responses:
        '200':
          description: XML
          content:
            application/xml:
              schema:
                type: string
        '500':
          $ref: '#/components/responses/ServerError'
  /sitemap-user/refresh:
    get:
      tags: [Sitemap]
      summary: Clear the user sitemap cache
      operationId: sitemapUsersRefresh
      x-audience: internal
      responses:
        '200':
          $ref: '#/components/responses/Loose'

  # ---------------------------------------------------------------- Blog
  /blog:
    get:
      tags: [Blog]
      summary: Published blog posts
      operationId: blogList
      x-audience: partner
      parameters:
        - $ref: '#/components/parameters/Lang'
        - name: category
          in: query
          schema:
            type: string
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
      responses:
        '200':
          description: Posts (`Cache-Control: public, max-age=300`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  posts:
                    type: array
                    items:
                      type: object
                      additionalProperties: true
                  total:
                    type: integer
                  lang:
                    type: string
                  category:
                    type: [string, 'null']
        '500':
          $ref: '#/components/responses/ServerError'
  /blog/{slug}:
    get:
      tags: [Blog]
      summary: Full blog article with sections and translations
      operationId: blogGet
      x-audience: partner
      parameters:
        - name: slug
          in: path
          required: true
          schema:
            type: string
        - $ref: '#/components/parameters/Lang'
      responses:
        '200':
          description: Post
          content:
            application/json:
              schema:
                type: object
                properties:
                  post:
                    type: object
                    additionalProperties: true
                  translations:
                    type: array
                    items:
                      type: object
                      properties:
                        language:
                          type: string
                        slug:
                          type: string
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Feedback
  /feature-request:
    post:
      tags: [Feedback]
      summary: Submit a feature request from the app
      operationId: feedbackFeatureRequest
      x-audience: consumer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              description: "`idea` (3–2000 chars) plus optional context; user token optional"
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'
  /feedback:
    post:
      tags: [Feedback]
      summary: Submit rating feedback from the app
      operationId: feedbackRating
      x-audience: consumer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: true
              description: "`rating` 1–5, `category` from the allowed set, optional message; user token optional"
      responses:
        '200':
          $ref: '#/components/responses/Loose'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

  # ---------------------------------------------------------------- Scraper
  /scraper/test:
    get:
      tags: [Scraper]
      summary: Connectivity test against the external scraper
      operationId: scraperTest
      x-audience: internal
      parameters:
        - name: domain
          in: query
          schema:
            type: string
      responses:
        '200':
          $ref: '#/components/responses/Loose'
  /scraper/scrape:
    post:
      tags: [Scraper]
      summary: Scrape a product URL
      operationId: scraperScrape
      x-audience: internal
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                forceFresh:
                  type: boolean
                externalOnly:
                  type: boolean
      responses:
        '200':
          description: "`{ success, data?, method?, cached?, error? }`"
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/ServerError'

components:
  securitySchemes:
    publicKey:
      type: http
      scheme: bearer
      description: "Partner public key `pk_live_…` or `pk_test_…` (`Authorization: Bearer pk_…`)."
    widgetToken:
      type: apiKey
      in: header
      name: X-Widget-Token
      description: Short-lived JWT from `POST /widget/init`, bound to the embedding origin.
    userToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Supabase user access token of the end user.

  headers:
    X-RateLimit-Limit:
      description: Requests per minute allowed for the plan.
      schema:
        type: integer
    X-RateLimit-Remaining:
      description: Remaining requests in the current minute (informational, never enforced).
      schema:
        type: integer
    X-RateLimit-Reset:
      description: ISO timestamp when the minute window resets.
      schema:
        type: string
    X-Usage-Month:
      description: Requests counted this calendar month.
      schema:
        type: integer
    X-Usage-Limit:
      description: Included monthly requests of the plan.
      schema:
        type: integer
    X-Usage-Overage:
      description: Overage amount accrued this month.
      schema:
        type: number
    X-Usage-Total:
      description: usage_event rows this month for the partner (widget/init only).
      schema:
        type: integer
    X-Usage-Remaining:
      description: Remaining events of the legacy monthly allowance (widget/init only).
      schema:
        type: integer

  parameters:
    Market:
      name: market
      in: query
      description: Market code such as `de-DE`, `en-US`. Some routes also accept `market_code`.
      schema:
        type: string
    Lang:
      name: lang
      in: query
      description: Language (`de`, `en`, …); mapped to a default market when `market` is absent.
      schema:
        type: string
    Limit:
      name: limit
      in: query
      schema:
        type: integer
    Offset:
      name: offset
      in: query
      schema:
        type: integer
        default: 0
    WindowDays:
      name: windowDays
      in: query
      schema:
        type: integer
        default: 7
        minimum: 1
        maximum: 90
    Sort:
      name: sort
      in: query
      schema:
        type: string
        enum: [popular, new]
        default: popular
    Country:
      name: country
      in: query
      schema:
        type: string
        default: DE
    IncludeAdult:
      name: include_adult
      in: query
      schema:
        type: boolean
        default: false
    PreviewToken:
      name: preview_token
      in: query
      description: Partner user access token; allows loading unpublished shops of that partner.
      schema:
        type: string
    DateFrom:
      name: from
      in: query
      schema:
        type: string
        format: date
    DateTo:
      name: to
      in: query
      schema:
        type: string
        format: date
    ShopHandle:
      name: handle
      in: path
      required: true
      description: Shop slug
      schema:
        type: string
    ListToken:
      name: token
      in: path
      required: true
      description: Wishlist share token (`shared_identifier`)
      schema:
        type: string
    ItemId:
      name: itemId
      in: path
      required: true
      schema:
        type: integer
    ProductId:
      name: productId
      in: path
      required: true
      schema:
        type: integer
    FriendId:
      name: friendId
      in: path
      required: true
      schema:
        type: string
    Id:
      name: id
      in: path
      required: true
      schema:
        type: string
    BasketId:
      name: id
      in: path
      required: true
      description: Basket configuration public id (`bkt_…`)
      schema:
        type: string

  responses:
    Ok:
      description: Success
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Ok'
    Success:
      description: Success
      content:
        application/json:
          schema:
            type: object
            properties:
              success:
                type: boolean
    Loose:
      description: JSON payload (shape not formally specified — see handler in routes/*.ts)
      content:
        application/json:
          schema:
            type: object
            additionalProperties: true
    BadRequest:
      description: Invalid input
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Missing or invalid token
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: Not allowed
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ServerError:
      description: Internal server error
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    SavedProducts:
      description: Saved products
      headers:
        X-RateLimit-Limit:
          $ref: '#/components/headers/X-RateLimit-Limit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/X-RateLimit-Remaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/X-RateLimit-Reset'
        X-Usage-Month:
          $ref: '#/components/headers/X-Usage-Month'
        X-Usage-Limit:
          $ref: '#/components/headers/X-Usage-Limit'
        X-Usage-Overage:
          $ref: '#/components/headers/X-Usage-Overage'
      content:
        application/json:
          schema:
            type: object
            properties:
              products:
                type: array
                items:
                  $ref: '#/components/schemas/SavedProduct'
    SaveResult:
      description: Saved
      content:
        application/json:
          schema:
            type: object
            properties:
              ok:
                type: boolean
              item_id:
                type: integer
              product_id:
                type: integer
    ShopProducts:
      description: Products
      content:
        application/json:
          schema:
            type: object
            properties:
              products:
                type: array
                items:
                  $ref: '#/components/schemas/ShopProduct'
    Series:
      description: Time series
      content:
        application/json:
          schema:
            type: object
            properties:
              series:
                type: array
                items:
                  type: object
                  additionalProperties: true
    FollowState:
      description: Follow state
      content:
        application/json:
          schema:
            type: object
            properties:
              followed:
                type: boolean
              followerCount:
                type: integer

  schemas:
    Status:
      type: object
      properties:
        status:
          type: string
          examples: [ok]
    Ok:
      type: object
      properties:
        ok:
          type: boolean
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
          description: Machine-readable code (snake_case) or a short message.
        message:
          type: string
        db_error:
          type: string
      additionalProperties: true
    SessionTokens:
      type: object
      required: [access_token, refresh_token]
      properties:
        access_token:
          type: string
        refresh_token:
          type: string
        expires_in:
          type: integer
          default: 3600
        token_type:
          type: string
          default: bearer
    WidgetInitResponse:
      type: object
      required: [token, partner_id, exp_minutes]
      properties:
        token:
          type: string
          description: Value for the `X-Widget-Token` header.
        partner_id:
          type: integer
        api_key_id:
          type: [integer, 'null']
        exp_minutes:
          type: integer
          examples: [10]
        overage:
          type: boolean
        basketConfig:
          type: [object, 'null']
          additionalProperties: true
          description: Layout/colour/checkout defaults of the partner's basket configuration.
    WidgetList:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
        image_path:
          type: string
        share_public:
          type: boolean
        productCount:
          type: integer
    PartnerMeta:
      type: object
      description: Checkout metadata sent by the SDK (`partnerMeta` or `metadata`).
      additionalProperties: true
      properties:
        siteDomain:
          type: string
        platform:
          type: string
          examples: [shopify]
        identifiers:
          type: object
          additionalProperties: true
          description: "`{ productId, variantId, sku }`"
        cart:
          type: object
          additionalProperties: true
          description: "`{ method, action, params }` used to add the product to the shop cart"
        buyNow:
          type: object
          additionalProperties: true
        vendor:
          type: string
        productType:
          type: string
        tags:
          type: array
          items:
            type: string
        compareAtPrice:
          type: integer
    SaveProductInput:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
        title:
          type: string
        description:
          type: string
        imageUrl:
          type: string
        price:
          type: integer
          description: Minor units (cents) for the widget flow.
        currency:
          type: string
          examples: [EUR]
        marketCode:
          type: string
          default: de-DE
        offerId:
          type: integer
        partnerMeta:
          $ref: '#/components/schemas/PartnerMeta'
        metadata:
          $ref: '#/components/schemas/PartnerMeta'
    SaveRequest:
      type: object
      required: [wishlistId, product]
      properties:
        wishlistId:
          type: integer
        product:
          $ref: '#/components/schemas/SaveProductInput'
    HostedSaveRequest:
      type: object
      required: [wishlistId, product]
      properties:
        wishlistId:
          type: integer
        product:
          type: object
          additionalProperties: true
          properties:
            url:
              type: string
              format: uri
            productId:
              type: integer
            rawProductId:
              type: integer
              description: AWIN feed product id from `/discover/search?scope=products`
            title:
              type: string
            description:
              type: string
            imageUrl:
              type: string
            price:
              type: number
              description: Decimal price for user overrides (values > 10000 are treated as minor units)
            currency:
              type: string
            marketCode:
              type: string
            offerId:
              type: integer
            sourceLabel:
              type: string
            priority:
              type: integer
              minimum: 1
              maximum: 4
            partnerMeta:
              $ref: '#/components/schemas/PartnerMeta'
    SavedProduct:
      type: object
      description: Product as returned by the widget saved-products endpoints.
      properties:
        id:
          type: integer
        wishlist_id:
          type: integer
        item_id:
          type: integer
        source_partner_id:
          type: [integer, 'null']
        source_shop_id:
          type: [integer, 'null']
        source_domain:
          type: string
        title:
          type: string
        description:
          type: string
        imageUrl:
          type: [string, 'null']
        image_url:
          type: [string, 'null']
        affiliateUrl:
          type: [string, 'null']
        url:
          type: [string, 'null']
        price_minor:
          type: [integer, 'null']
        price:
          type: [integer, 'null']
        currency:
          type: [string, 'null']
        compare_at_price_minor:
          type: [integer, 'null']
        brand:
          type: [string, 'null']
        product_type:
          type: [string, 'null']
        tags:
          type: [array, 'null']
          items:
            type: string
        checkoutMeta:
          type: [object, 'null']
          additionalProperties: true
        variantId:
          type: [string, 'null']
    HostedProduct:
      type: object
      description: Product as returned by hosted list endpoints.
      properties:
        id:
          type: integer
        wishlist_id:
          type: integer
        item_id:
          type: integer
        purchased:
          type: boolean
        title:
          type: string
        description:
          type: string
        imageUrl:
          type: [string, 'null']
        affiliateUrl:
          type: [string, 'null']
        price_minor:
          type: [integer, 'null']
        currency:
          type: [string, 'null']
        source_label:
          type: [string, 'null']
        priority:
          type: [integer, 'null']
        rating:
          type: [number, 'null']
        review_count:
          type: [integer, 'null']
        brand:
          type: [string, 'null']
        added_at:
          type: [string, 'null']
    HostedList:
      type: object
      properties:
        id:
          type: string
          description: Share token (use it in `/hosted/lists/{token}`)
        db_id:
          type: integer
          description: Numeric id (use it as `wishlistId` in save calls)
        name:
          type: string
        description:
          type: string
        image_path:
          type: string
        share_public:
          type: boolean
        occasion_slug:
          type: [string, 'null']
        productCount:
          type: integer
        collaboratorCount:
          type: integer
        is_shared:
          type: boolean
        role:
          type: string
          enum: [owner, editor, viewer]
        owner_id:
          type: string
        owner_name:
          type: [string, 'null']
        owner_avatar:
          type: [string, 'null']
        updated_at:
          type: [string, 'null']
    HostedListDetail:
      type: object
      properties:
        id:
          type: string
        internal_id:
          type: integer
        name:
          type: string
        description:
          type: string
        image_path:
          type: string
        share_public:
          type: boolean
        created_at:
          type: string
        updated_at:
          type: string
        occasion_slug:
          type: [string, 'null']
        occasion_title:
          type: [string, 'null']
        productCount:
          type: integer
        owner_username:
          type: [string, 'null']
        owner_avatar:
          type: [string, 'null']
        owner_is_premium:
          type: boolean
    ItemOverridePatch:
      type: object
      properties:
        purchased:
          type: boolean
        title:
          type: [string, 'null']
        description:
          type: [string, 'null']
        image_path:
          type: [string, 'null']
        offer_id:
          type: [integer, 'null']
        price_minor:
          type: [integer, 'null']
        price_currency:
          type: [string, 'null']
        source_label:
          type: [string, 'null']
        priority:
          type: [integer, 'null']
    PurchasedBody:
      type: object
      properties:
        purchased:
          type: boolean
    ScrapedProduct:
      type: object
      additionalProperties: true
      properties:
        title:
          type: string
        description:
          type: string
        image:
          type: string
        imageUrl:
          type: string
        price:
          type: number
        currency:
          type: string
        _method:
          type: string
        _cached:
          type: boolean
    ShopPublic:
      type: object
      additionalProperties: true
      properties:
        id:
          type: integer
        userId:
          type: integer
        name:
          type: string
        handle:
          type: string
        domain:
          type: string
        description:
          type: string
        template:
          type: string
        coverImage:
          type: string
        logo:
          type: string
        settings:
          type: object
          additionalProperties: true
        status:
          type: string
        publishedAt:
          type: string
        productCount:
          type: integer
        listCount:
          type: integer
        updatedAt:
          type: string
        allowedMarkets:
          type: array
          items:
            type: string
        defaultMarket:
          type: [string, 'null']
        followerCount:
          type: integer
    ShopList:
      type: object
      additionalProperties: true
      properties:
        id:
          type: integer
        slug:
          type: string
        name:
          type: string
        description:
          type: string
        coverImage:
          type: string
        productCount:
          type: integer
        updatedAt:
          type: string
        isFeatured:
          type: boolean
        isPublic:
          type: boolean
        market:
          type: [string, 'null']
    DiscoverList:
      type: object
      additionalProperties: true
      properties:
        id:
          type: integer
        slug:
          type: string
        name:
          type: string
        description:
          type: string
        coverImage:
          type: string
        productCount:
          type: integer
        updatedAt:
          type: string
        shopSlug:
          type: string
        shopName:
          type: string
        shopLogo:
          type: string
    ShopProduct:
      type: object
      additionalProperties: true
      properties:
        id:
          type: integer
        title:
          type: string
        description:
          type: string
        imageUrl:
          type: string
        affiliateUrl:
          type: [string, 'null']
        price_minor:
          type: [integer, 'null']
        currency:
          type: [string, 'null']
        offerId:
          type: [integer, 'null']
        rating:
          type: [number, 'null']
        review_count:
          type: [integer, 'null']
        brand:
          type: [string, 'null']
    TrendingProducts:
      type: object
      additionalProperties: true
      properties:
        products:
          type: array
          items:
            $ref: '#/components/schemas/ShopProduct'
        source:
          type: string
        windowDays:
          type: integer
        weights:
          type: object
          additionalProperties:
            type: number
        scope:
          type: string
    PricePoint:
      type: object
      properties:
        price_minor:
          type: integer
        currency:
          type: string
        captured_at:
          type: string
    Store:
      type: object
      additionalProperties: true
      properties:
        id:
          type: integer
        slug:
          type: string
        name:
          type: string
        baseUrl:
          type: string
        logoPath:
          type: [string, 'null']
        category:
          type: [string, 'null']
        isFeatured:
          type: boolean
        description:
          type: [string, 'null']
        supportedCountries:
          type: array
          items:
            type: string
        affiliateNetwork:
          type: [string, 'null']
    Pagination:
      type: object
      properties:
        limit:
          type: integer
        offset:
          type: integer
        hasMore:
          type: boolean
    BasketConfig:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        layout:
          type: string
        checkout:
          type: string
        colors:
          type: object
          properties:
            primary:
              type: string
            text:
              type: string
            background:
              type: string
        typography:
          type: object
          properties:
            fontFamily:
              type: string
            buttonText:
              type: string
        styling:
          type: object
          properties:
            borderRadius:
              type: integer
            animation:
              type: string
        cartUrl:
          type: [string, 'null']
        webhookUrl:
          type: [string, 'null']
        fallbackText:
          type: [string, 'null']
        cartButtonText:
          type: [string, 'null']
        viewButtonText:
          type: [string, 'null']
        shopName:
          type: [string, 'null']
        exploreUrl:
          type: [string, 'null']
        popularUrl:
          type: [string, 'null']
    Media:
      type: object
      description: Product video row (shape defined by lib/media.ts formatMedia; loose until stable).
      additionalProperties: true
      properties:
        id:
          type: integer
        product_id:
          type: integer
        partner_id:
          type: integer
        shop_id:
          type: [integer, 'null']
        status:
          type: string
          description: ready | hidden | rejected | processing
        url:
          type: [string, 'null']
        poster_url:
          type: [string, 'null']
        title:
          type: [string, 'null']
        caption:
          type: [string, 'null']
        language:
          type: [string, 'null']
        duration_s:
          type: [number, 'null']
        width:
          type: [integer, 'null']
        height:
          type: [integer, 'null']
        position:
          type: integer
        created_at:
          type: string
    MediaCreate:
      type: object
      required: [product_id, storage_path]
      additionalProperties: true
      properties:
        product_id:
          type: integer
        shop_id:
          type: integer
        partner_id:
          type: integer
        storage_path:
          type: string
        poster_path:
          type: string
        mime:
          type: string
        bytes:
          type: integer
        duration_s:
          type: number
        width:
          type: integer
        height:
          type: integer
        title:
          type: string
        caption:
          type: string
        language:
          type: string
    AnalyticsQuery:
      type: object
      properties:
        partnerId:
          type: integer
        apiKeyId:
          type: integer
        origin:
          type: string
        start:
          type: string
          format: date-time
        end:
          type: string
          format: date-time
        groupBy:
          type: string
          enum: [day, hour]
    ApiUsage:
      type: object
      properties:
        api_key_id:
          type: integer
        partner_id:
          type: integer
        environment:
          type: string
          enum: [prod, test]
        current_usage:
          type: object
          properties:
            minute:
              type: integer
            hour:
              type: integer
            day:
              type: integer
            month:
              type: integer
        limits:
          type: object
          properties:
            rate_per_minute:
              type: integer
            monthly_calls:
              type: integer
        plan_info:
          type: object
          properties:
            type:
              type: string
              description: trial | creator | premium-creator | api | combined
            overage_price_per_1k:
              type: number
            has_webhooks:
              type: boolean
            has_production_keys:
              type: boolean
            combined_plan_info:
              type: object
              additionalProperties: true
        billing:
          type: object
          properties:
            overage_amount:
              type: number
            percentage_used:
              type: number
            remaining_calls:
              type: integer
