openapi: 3.0.3
info:
  title: Betsy Enterprise API (Brand Intelligence)
  description: |
    Programmatic access to **anonymized** search demand for Brand Intelligence
    tenants (OEM or Platform editions).

    **Not for GSA dealers** — dealers use seller-scoped onboarding intel and leads
    (no `ek_live_` market API).

    ## Authentication

    ```
    Authorization: Bearer ek_live_…
    ```

    Keys are minted in Admin → Clients or by a portal `client_admin`
    (`POST /v1/keys`). The full secret is shown **once** at creation.

    Portal browser sessions use the `gse_enterprise` cookie for the same data
    routes. Key minting and settings PATCH require a portal session +
    `client_admin` role.

    ## Editions & OEM scope

    | Edition | Scope |
    |---------|--------|
    | `platform` | Full-market anonymized demand |
    | `oem` | Only data matching `brand_keys` + `competitor_brand_keys` (max 3 competitors) |

    **OEM with empty brand keys fails closed** — empty intel/sessions/export
    (never silent full platform). All data responses include a `scope` object.

    ## Privacy

    - **Never returned:** emails, session ids, seller hosts, product URLs, internal lead ids
    - **May include:** free-text `q`, referrer host/path, landing UTM, `has_email`, public catalog fields on UPC board
    - Customers must not re-identify individuals from residual free text; treat exports as confidential demand data
    - Policy draft: https://www.gunsearchengine.com/privacy

    ## Rate limits

    | Class | Limit |
    |-------|--------|
    | read (intel, sessions, slices, me, health) | 60 / min / IP |
    | export | 10 / min / IP |

    Human-readable docs: https://www.gunsearchengine.com/docs/enterprise-api
  version: "1.0.0"
  contact:
    name: Coriolis / GunSearchEngine
    url: https://www.gunsearchengine.com/betsy/enterprise
  license:
    name: Proprietary
    url: https://www.gunsearchengine.com/betsy/enterprise

servers:
  - url: https://www.gunsearchengine.com/api/enterprise
    description: Production
  - url: https://gunsearchengine.com/api/enterprise
    description: Apex (redirects to www)

tags:
  - name: Bootstrap
    description: Liveness, identity, and client settings
  - name: Demand
    description: Anonymized search demand (cloud, UPC, unmet, rising, referrers)
  - name: Sessions
    description: Sanitized session feed and export (no PII)
  - name: API Keys
    description: Portal admin key management (cookie session only)

paths:
  /v1/health:
    get:
      tags: [Bootstrap]
      operationId: getHealth
      summary: Liveness + scope ready flag
      description: Authenticated liveness check for monitors and SDK bootstrap.
      security:
        - ApiKeyAuth: []
        - PortalCookie: []
      responses:
        "200":
          description: Service is up for this client
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/me:
    get:
      tags: [Bootstrap]
      operationId: getMe
      summary: Client, scope, portal path
      description: Returns the authenticated client and brand scope. Includes `user` when authenticated via portal cookie.
      security:
        - ApiKeyAuth: []
        - PortalCookie: []
      responses:
        "200":
          description: Current client context
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/settings:
    get:
      tags: [Bootstrap]
      operationId: getSettings
      summary: Weekly insights + edition keys
      security:
        - ApiKeyAuth: []
        - PortalCookie: []
      responses:
        "200":
          description: Client settings
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SettingsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"
    patch:
      tags: [Bootstrap]
      operationId: patchSettings
      summary: Toggle weekly insights
      description: |
        Requires portal cookie session with `client_admin` role.
        API keys cannot patch settings.
      security:
        - PortalCookie: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [weekly_insights_enabled]
              properties:
                weekly_insights_enabled:
                  type: boolean
      responses:
        "200":
          description: Updated settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  weekly_insights_enabled:
                    type: boolean
                  scope:
                    $ref: "#/components/schemas/Scope"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/intel:
    get:
      tags: [Demand]
      operationId: getIntel
      summary: Full demand package
      description: |
        Full demand package for the window — cloud, funnel, map, UPC leaderboard,
        unmet demand, rising calibers, referrers, and accuracy metadata.
      security:
        - ApiKeyAuth: []
        - PortalCookie: []
      parameters:
        - $ref: "#/components/parameters/Hours"
      responses:
        "200":
          description: Demand intelligence payload
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IntelResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/unmet:
    get:
      tags: [Demand]
      operationId: getUnmet
      summary: Zero-hit “Asked, not found”
      security:
        - ApiKeyAuth: []
        - PortalCookie: []
      parameters:
        - $ref: "#/components/parameters/Hours"
      responses:
        "200":
          description: Unmet demand slice
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UnmetResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/upc:
    get:
      tags: [Demand]
      operationId: getUpc
      summary: SKU intensity leaderboard
      security:
        - ApiKeyAuth: []
        - PortalCookie: []
      parameters:
        - $ref: "#/components/parameters/Hours"
      responses:
        "200":
          description: UPC leaderboard slice
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpcResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/rising:
    get:
      tags: [Demand]
      operationId: getRising
      summary: Rising calibers (rollups)
      description: Default window is 72 hours (still clamped to 1–168).
      security:
        - ApiKeyAuth: []
        - PortalCookie: []
      parameters:
        - name: hours
          in: query
          description: Lookback window in hours (1–168). Default **72** for this route.
          schema:
            type: integer
            minimum: 1
            maximum: 168
            default: 72
      responses:
        "200":
          description: Rising calibers slice
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/RisingResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/referrers:
    get:
      tags: [Demand]
      operationId: getReferrers
      summary: First-touch external referrer hosts
      security:
        - ApiKeyAuth: []
        - PortalCookie: []
      parameters:
        - $ref: "#/components/parameters/Hours"
      responses:
        "200":
          description: Top referrers slice
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ReferrersResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/sessions:
    get:
      tags: [Sessions]
      operationId: listSessions
      summary: Sanitized session feed
      description: |
        Paginated sanitized sessions. Never includes emails, session ids,
        seller hosts, or product URLs.
      security:
        - ApiKeyAuth: []
        - PortalCookie: []
      parameters:
        - name: q
          in: query
          description: Free-text filter on intent label / params
          schema:
            type: string
            maxLength: 80
        - name: channel
          in: query
          description: Channel filter
          schema:
            type: string
            enum: [all, web, embed]
            default: all
        - name: term
          in: query
          description: Term filter against params / summary
          schema:
            type: string
            maxLength: 80
        - name: kind
          in: query
          description: Reserved demand-kind filter (may be ignored)
          schema:
            type: string
            maxLength: 32
        - name: limit
          in: query
          description: Page size (1–100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 40
        - name: offset
          in: query
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        "200":
          description: Sanitized session page
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionsResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/sessions/export:
    get:
      tags: [Sessions]
      operationId: exportSessions
      summary: JSON download of sanitized sessions
      description: |
        JSON download of up to 5,000 sanitized sessions (optional events).
        Audited. Content-Disposition attachment filename is set.
      security:
        - ApiKeyAuth: []
        - PortalCookie: []
      parameters:
        - name: q
          in: query
          schema:
            type: string
            maxLength: 80
        - name: channel
          in: query
          schema:
            type: string
            enum: [all, web, embed]
            default: all
        - name: term
          in: query
          schema:
            type: string
            maxLength: 80
        - name: events
          in: query
          description: Include intent events when not `0` (default includes events)
          schema:
            type: string
            enum: ["0", "1"]
            default: "1"
      responses:
        "200":
          description: Sanitized export JSON (file download)
          headers:
            Content-Disposition:
              schema:
                type: string
                example: attachment; filename="betsy-sessions-sanitized-2026-08-02-12-00-00.json"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SessionsExportResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerError"

  /v1/keys:
    get:
      tags: [API Keys]
      operationId: listKeys
      summary: List API keys
      description: Portal `client_admin` cookie required.
      security:
        - PortalCookie: []
      responses:
        "200":
          description: Key list (prefixes only; secrets never re-shown)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KeysListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [API Keys]
      operationId: createKey
      summary: Create API key
      description: |
        Portal `client_admin` cookie required. Returns the full `secret` **once**.
      security:
        - PortalCookie: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 80
                  default: API key
      responses:
        "200":
          description: Minted key (secret shown once)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/KeyCreateResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    delete:
      tags: [API Keys]
      operationId: revokeKey
      summary: Revoke API key
      description: Portal `client_admin` cookie required.
      security:
        - PortalCookie: []
      parameters:
        - name: id
          in: query
          required: true
          description: Key id to revoke
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: Key revoked (or already revoked)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: ek_live_
      description: |
        Enterprise API key. Header:
        `Authorization: Bearer ek_live_…`
    PortalCookie:
      type: apiKey
      in: cookie
      name: gse_enterprise
      description: |
        HMAC portal session cookie. Used by the Brand Intelligence portal
        browser; required for key management and settings PATCH.

  parameters:
    Hours:
      name: hours
      in: query
      description: Lookback window in hours (1–168). ET calendar windows. Default 24.
      schema:
        type: integer
        minimum: 1
        maximum: 168
        default: 24

  responses:
    Unauthorized:
      description: Missing or invalid credentials
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: Unauthorized
    Forbidden:
      description: Authenticated but not allowed for this action
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: Forbidden
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Rate limit exceeded
      content:
        application/json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Error"
              - type: object
                properties:
                  code:
                    type: string
                    example: rate_limited
          example:
            error: Rate limit exceeded
            code: rate_limited
    ServerError:
      description: Unexpected server error
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
        code:
          type: string

    Scope:
      type: object
      description: Edition / brand allowlist metadata on every data response
      properties:
        edition:
          type: string
          enum: [platform, oem]
        kind:
          type: string
          enum: [all, allowlist, none]
          description: |
            `all` = platform full market; `allowlist` = OEM brands;
            `none` = OEM unconfigured (fail closed)
        brands:
          type: array
          items:
            type: string
          example: [glock, federal]
        configured:
          type: boolean
          description: false when OEM scope kind is `none`

    CloudTerm:
      type: object
      properties:
        term:
          type: string
        weight:
          type: number
        kind:
          type: string
          description: type | caliber | brand | load | use
        byType:
          type: object
          additionalProperties:
            type: number
          description: Session weights by product type family

    Funnel:
      type: object
      properties:
        browse:
          type: integer
        cart:
          type: integer
        checkout:
          type: integer
        purchase:
          type: integer

    UsStateDemand:
      type: object
      properties:
        state:
          type: string
          description: US state code
        sessions:
          type: integer
        sales:
          type: integer

    UpcLeaderboardEntry:
      type: object
      properties:
        rank:
          type: integer
        upc:
          type: string
          nullable: true
        title:
          type: string
        brand:
          type: string
          nullable: true
        caliber:
          type: string
          nullable: true
        type:
          type: string
          nullable: true
        price:
          type: string
          nullable: true
        image_url:
          type: string
          nullable: true
        weight:
          type: number
          description: Distinct demand sessions (search or view)
        score:
          type: number
          description: Relative intensity 0–1 vs #1
        in_stock:
          type: boolean
          nullable: true
        hits:
          type: integer

    UnmetDemandEntry:
      type: object
      properties:
        term:
          type: string
        weight:
          type: number
        kind:
          type: string

    RisingCaliber:
      type: object
      properties:
        term:
          type: string
        sessions:
          type: integer

    DemandReferrer:
      type: object
      properties:
        host:
          type: string
        count:
          type: integer
        kind:
          type: string
          nullable: true

    CloudMethod:
      type: string
      enum: [live_sample, period_rollups, period_scan]
      description: |
        How the demand cloud was built:
        - `live_sample` — recent session bag (Today / short windows)
        - `period_rollups` — durable daily rollups (multi-day platform)
        - `period_scan` — brand-filtered full-window scan (OEM multi-day)

    HealthResponse:
      type: object
      properties:
        ok:
          type: boolean
        service:
          type: string
          example: gunsearchengine-enterprise-v1
        time:
          type: string
          format: date-time
        client_id:
          type: integer
        edition:
          type: string
          enum: [platform, oem]
        active:
          type: boolean
        scope:
          $ref: "#/components/schemas/Scope"
        ready_for_reads:
          type: boolean
        message:
          type: string

    MeResponse:
      type: object
      properties:
        via:
          type: string
          enum: [session, api_key]
        user:
          type: object
          nullable: true
          properties:
            id:
              type: integer
            email:
              type: string
            name:
              type: string
              nullable: true
            role:
              type: string
              enum: [client_admin, client_viewer]
        client:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            slug:
              type: string
            edition:
              type: string
              enum: [platform, oem]
            brand_keys:
              type: array
              items:
                type: string
            competitor_brand_keys:
              type: array
              items:
                type: string
            agent_label:
              type: string
              nullable: true
            brand_color:
              type: string
              nullable: true
            logo_url:
              type: string
              nullable: true
            weekly_insights_enabled:
              type: boolean
            weekly_insights_last_sent_on:
              type: string
              nullable: true
        scope:
          $ref: "#/components/schemas/Scope"
        impersonating:
          type: boolean
        portal_path:
          type: string
          example: /betsy/sports_south

    SettingsResponse:
      type: object
      properties:
        weekly_insights_enabled:
          type: boolean
        weekly_insights_last_sent_on:
          type: string
          nullable: true
        edition:
          type: string
          enum: [platform, oem]
        brand_keys:
          type: array
          items:
            type: string
        competitor_brand_keys:
          type: array
          items:
            type: string
        scope:
          $ref: "#/components/schemas/Scope"

    IntelResponse:
      type: object
      description: Full demand package from GET /v1/intel
      properties:
        generatedAt:
          type: string
          format: date-time
        hours:
          type: integer
        windowLabel:
          type: string
        timeZone:
          type: string
          example: America/New_York
        sessions:
          type: integer
        hostBeacons:
          type: integer
        cloud:
          type: object
          description: Demand word cloud by kind
          properties:
            type:
              type: array
              items:
                $ref: "#/components/schemas/CloudTerm"
            caliber:
              type: array
              items:
                $ref: "#/components/schemas/CloudTerm"
            brand:
              type: array
              items:
                $ref: "#/components/schemas/CloudTerm"
            load:
              type: array
              items:
                $ref: "#/components/schemas/CloudTerm"
            use:
              type: array
              items:
                $ref: "#/components/schemas/CloudTerm"
        funnel:
          $ref: "#/components/schemas/Funnel"
        usStates:
          type: array
          items:
            $ref: "#/components/schemas/UsStateDemand"
        geoSessionsUs:
          type: integer
        geoSalesUs:
          type: integer
        upcLeaderboard:
          type: array
          items:
            $ref: "#/components/schemas/UpcLeaderboardEntry"
        unmetDemand:
          type: array
          items:
            $ref: "#/components/schemas/UnmetDemandEntry"
        risingCalibers:
          type: array
          items:
            $ref: "#/components/schemas/RisingCaliber"
        topReferrers:
          type: array
          items:
            $ref: "#/components/schemas/DemandReferrer"
        sampleSessions:
          type: integer
        sampleCap:
          type: integer
        windowSessions:
          type: integer
        cloudMethod:
          $ref: "#/components/schemas/CloudMethod"
        conversionRateLast24h:
          type: number
          nullable: true
        conversionRatePrev24h:
          type: number
          nullable: true
        conversionDeltaPp:
          type: number
          nullable: true
        sessionsDeltaPct:
          type: number
          nullable: true
        conversionCompareHours:
          type: integer
        scope:
          $ref: "#/components/schemas/Scope"

    UnmetResponse:
      type: object
      properties:
        hours:
          type: integer
        windowLabel:
          type: string
        timeZone:
          type: string
        cloudMethod:
          $ref: "#/components/schemas/CloudMethod"
        windowSessions:
          type: integer
        scope:
          $ref: "#/components/schemas/Scope"
        unmetDemand:
          type: array
          items:
            $ref: "#/components/schemas/UnmetDemandEntry"

    UpcResponse:
      type: object
      properties:
        hours:
          type: integer
        windowLabel:
          type: string
        timeZone:
          type: string
        cloudMethod:
          $ref: "#/components/schemas/CloudMethod"
        sampleSessions:
          type: integer
        windowSessions:
          type: integer
        scope:
          $ref: "#/components/schemas/Scope"
        upcLeaderboard:
          type: array
          items:
            $ref: "#/components/schemas/UpcLeaderboardEntry"

    RisingResponse:
      type: object
      properties:
        hours:
          type: integer
        windowLabel:
          type: string
        timeZone:
          type: string
        scope:
          $ref: "#/components/schemas/Scope"
        risingCalibers:
          type: array
          items:
            $ref: "#/components/schemas/RisingCaliber"

    ReferrersResponse:
      type: object
      properties:
        hours:
          type: integer
        windowLabel:
          type: string
        timeZone:
          type: string
        cloudMethod:
          $ref: "#/components/schemas/CloudMethod"
        scope:
          $ref: "#/components/schemas/Scope"
        topReferrers:
          type: array
          items:
            $ref: "#/components/schemas/DemandReferrer"

    SanitizedSession:
      type: object
      description: Partner-safe session row (no emails, session ids, seller hosts, product URLs)
      additionalProperties: true
      properties:
        row:
          type: integer
        channel:
          type: string
          enum: [web, embed]
        has_email:
          type: boolean
        has_watch:
          type: boolean
        label:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
          nullable: true
        last_seen_at:
          type: string
          format: date-time
          nullable: true
        params:
          type: object
          additionalProperties: true
          description: Safe search-slot keys only
        summary:
          type: object
          additionalProperties: true
          description: Safe intent summary fields only

    SessionsResponse:
      type: object
      properties:
        total:
          type: integer
        sessions:
          type: array
          items:
            $ref: "#/components/schemas/SanitizedSession"
        limit:
          type: integer
        offset:
          type: integer
        scope:
          $ref: "#/components/schemas/Scope"

    SessionsExportResponse:
      type: object
      properties:
        exported_at:
          type: string
          format: date-time
        note:
          type: string
        filter:
          type: string
        sanitized:
          type: boolean
          example: true
        total_matching:
          type: integer
        exported_count:
          type: integer
        truncated:
          type: boolean
        limit:
          type: integer
          example: 5000
        sessions:
          type: array
          items:
            $ref: "#/components/schemas/SanitizedSession"
        scope:
          $ref: "#/components/schemas/Scope"

    ApiKeySummary:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
        key_prefix:
          type: string
          example: ek_live_abcd
        last_used_at:
          type: string
          format: date-time
          nullable: true
        revoked_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        active:
          type: boolean

    KeysListResponse:
      type: object
      properties:
        keys:
          type: array
          items:
            $ref: "#/components/schemas/ApiKeySummary"

    KeyCreateResponse:
      type: object
      properties:
        key:
          type: object
          properties:
            id:
              type: integer
            name:
              type: string
            key_prefix:
              type: string
            secret:
              type: string
              description: Full secret — shown once; store immediately
              example: ek_live_…
            created_at:
              type: string
              format: date-time
