# YubHub Public REST API
#
# ⚠️ THIS FILE IS HAND-MAINTAINED.
#    Any change to a public endpoint in v2/workers/admin-api/src/ is a
#    breaking change to this file. Keep them in sync. The `/health` endpoint
#    exposes the deployed version — use it to spot-check.
#
# Dynamic endpoints to watch when updating:
#   • /search, /stats/*, /stats/facets/*, /stats/by-facet/*, /stats/job/:id,
#     /stats/company/:slug, /stats/recent-jobs, /health
#
# Authenticated endpoints (requiring a `yh_*` API key) are documented for
# completeness but marked with `x-internal: true`. Third parties should use
# the public `/stats/*` and `/search` endpoints for read-only access.

openapi: 3.0.3
info:
  title: YubHub Public API
  version: "2.5.0"
  description: |
    Live UK + global job data from direct scraping of employer careers pages,
    enriched with AI for structured salary, skills, work arrangement and
    experience. Public endpoints are free; API-tier features require a yh_
    API key (`api_pro` plan). See `/developers` on the main site for details.
  contact:
    name: Houtini
    url: https://houtini.com
    email: hello@houtini.com
  license:
    name: MIT
    url: https://github.com/richybaxter/yubhub
servers:
  - url: https://api.yubhub.co
    description: Production
tags:
  - name: Health
  - name: Search
  - name: Stats
  - name: Facets
  - name: Jobs
  - name: Companies
  - name: Public feeds (feeds.yubhub.co)
  - name: Feeds (authenticated)
  - name: Account (authenticated)
paths:
  /facet/{type}/{slug}.xml:
    servers:
      - url: https://feeds.yubhub.co
        description: Public feed host
    get:
      tags: [Public feeds (feeds.yubhub.co)]
      summary: Free XML job feed for a facet (JBoard/SmartJobBoard-compatible) — on feeds.yubhub.co
      description: |
        No auth. `https://feeds.yubhub.co/facet/{type}/{slug}.xml` — e.g.
        `/facet/skill/python.xml`, `/facet/category/sales.xml`, `/facet/company/anthropic.xml`.
        Contains the **100 most recently enriched** live jobs for the facet, newest first
        (the payload says so in `x-feed-notice`); the full corpus is the paginated
        `/stats/by-facet/{type}/{value}` API. Regenerated on demand from the live database
        and cached ~1 hour (KV + edge), so it reflects the database within an hour of a change;
        how often the database changes depends on the plan of the source employer feeds
        (daily on Publisher / API Pro, weekly on Free / Basic). Free use requires the
        attribution link carried in the feed; paid plans remove it. Every job page links
        these as `<link rel="alternate">` feeds. `X-Robots-Tag: noindex`.
      parameters:
        - { name: type, in: path, required: true, schema: { type: string, enum: [title, category, industry, skill, company] } }
        - { name: slug, in: path, required: true, schema: { type: string }, description: Facet slug as used on yubhub.co URLs }
      responses:
        '200': { description: JBoard-format XML (`<jobs><job>…</job></jobs>`), content: { application/xml: {} } }
        '404': { description: Unknown facet type or slug }

  /facet/{type}/{slug}.json:
    servers:
      - url: https://feeds.yubhub.co
        description: Public feed host
    get:
      tags: [Public feeds (feeds.yubhub.co)]
      summary: Free JSON job feed for a facet — on feeds.yubhub.co
      description: Same content and caching as the `.xml` twin, as JSON (`company`, `x-facet`, `jobs[]` with title, company, location, description HTML, employment type, salary where stated, posting date and the original application URL).
      parameters:
        - { name: type, in: path, required: true, schema: { type: string, enum: [title, category, industry, skill, company] } }
        - { name: slug, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: JSON feed, content: { application/json: {} } }
        '404': { description: Unknown facet type or slug }

  /feed/{feedId}:
    servers:
      - url: https://feeds.yubhub.co
        description: Public feed host
    get:
      tags: [Public feeds (feeds.yubhub.co)]
      summary: XML feed for one employer feed (the URL shown in the dashboard) — on feeds.yubhub.co
      description: JBoard-format XML of all live jobs in a feed. Regenerated after each feed run; cached ~1 hour at the edge. `feedId` is the `feed_…` identifier; `GET /api/feeds/{id}/xml` (authenticated) redirects here.
      parameters:
        - { name: feedId, in: path, required: true, schema: { type: string, pattern: '^feed_[a-f0-9]+$' } }
      responses:
        '200': { description: JBoard-format XML, content: { application/xml: {} } }
        '404': { description: Unknown or deleted feed }

  /health:
    get:
      tags: [Health]
      summary: Service health + deployed version
      responses:
        '200':
          description: Healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: healthy }
                  worker: { type: string, example: admin-api }
                  environment: { type: string, example: staging }
                  authEnabled: { type: boolean }
                  timestamp: { type: integer, format: int64 }

  /search:
    get:
      tags: [Search]
      summary: Full-text search across job titles and companies
      description: >-
        FTS5-backed (2026-09-06). Each word in `q` matches as a word prefix
        ("account" matches "Accountant", "count" does not); all words must
        match. Results are edge-cached for up to 4 hours. Rate-limited to
        30 requests per minute per IP (HTTP 429, 10-minute cooldown).
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, minLength: 2, maxLength: 100 } }
        - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: perPage, in: query, schema: { type: integer, minimum: 10, maximum: 30, default: 30 } }
      responses:
        '200': { $ref: '#/components/responses/SearchResult' }
        '429': { description: Rate limited (30 req/min per IP). Retry after the `Retry-After` seconds. }

  /stats/overview:
    get:
      tags: [Stats]
      summary: Site-wide totals
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      total_jobs: { type: integer }
                      total_companies: { type: integer }
                      total_feeds: { type: integer }
                      data_range:
                        type: object
                        properties:
                          earliest: { type: string, format: date }
                          latest: { type: string, format: date }

  /stats/facets/{type}:
    get:
      tags: [Facets]
      summary: List facet values with counts (for directory index pages)
      parameters:
        - { name: type, in: path, required: true, schema: { type: string, enum: [title, category, industry, company, skill] } }
        - { name: min, in: query, schema: { type: integer, default: 5 }, description: Minimum job count to include }
      responses:
        '200': { $ref: '#/components/responses/FacetList' }

  /stats/by-facet/{type}/{value}:
    get:
      tags: [Facets]
      summary: Detailed data for a single facet value (paginated job list + aggregations)
      parameters:
        - { name: type, in: path, required: true, schema: { type: string, enum: [title, category, industry, company, skill] } }
        - { name: value, in: path, required: true, schema: { type: string } }
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: perPage, in: query, schema: { type: integer, minimum: 10, maximum: 100, default: 50 } }
      responses:
        '200': { $ref: '#/components/responses/FacetDetail' }

  /stats/related/{type}/{value}:
    get:
      tags: [Facets]
      summary: Related facets (cross-link suggestions for archive sidebars)
      parameters:
        - { name: type, in: path, required: true, schema: { type: string, enum: [title, category, industry, company, skill] } }
        - { name: value, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      related:
                        type: object
                        additionalProperties:
                          type: array
                          items: { $ref: '#/components/schemas/FacetEntry' }

  /stats/job/{id}:
    get:
      tags: [Jobs]
      summary: Full data for a single job including linked-data (Wikidata sameAs)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: 'Job ID (e.g. job_abc123def456)' }
      responses:
        '200': { $ref: '#/components/responses/JobDetail' }
        '404': { description: Job not found }

  /stats/recent-jobs:
    get:
      tags: [Jobs]
      summary: Recent enriched job IDs (for sitemap consumers)
      parameters:
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 5000, default: 1000 } }
        - { name: offset, in: query, schema: { type: integer, minimum: 0, default: 0 }, description: 'Pagination offset. Results are ordered by enriched_at DESC; pass offset=limit to get page 2, offset=limit*2 for page 3, etc. Used by /sitemap-jobs-{1,2,3}.xml to cover >5k enriched jobs.' }
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        enriched_at: { type: integer, format: int64 }

  /stats/company/{slug}:
    get:
      tags: [Companies]
      summary: Full company intelligence profile + hiring aggregates + 11 benchmarking sections
      description: |
        Returns the company profile, base hiring aggregates, and 11 additive Phase 1
        intelligence sections under `data.hiring.*`: velocity (26-week), momentum (14d),
        salary_vs_industry (with regional decomposition + transparency caveat),
        market_share, category_normalised (10-bucket map shared with /live),
        ai_exposure (occupation- + skill-weighted), peer_set (Jaccard on canonical
        categories within same industry), skills_lq (Location Quotient against peer
        baseline — the "F1 aerodynamics" view), geographic_shift (current/emerging/
        shrinking regions), seniority_anomalies (executive-hire isolation),
        posting_dynamics (median days open, ghost-job count, closure rate).
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
      responses:
        '200': { $ref: '#/components/responses/CompanyDetail' }
        '404': { description: Company not found }

  /stats/company/{slug}/timeseries:
    get:
      tags: [Companies]
      summary: Per-company time series (velocity / categories / titles / geo)
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
        - { name: metric, in: query, required: false, schema: { type: string, enum: [velocity, categories, titles, geo], default: velocity } }
        - { name: weeks, in: query, required: false, schema: { type: integer, minimum: 4, maximum: 104, default: 26 } }
      responses:
        '200': { $ref: '#/components/responses/GenericData' }
        '404': { description: Company not found }

  /stats/company/{slug}/peers:
    get:
      tags: [Companies]
      summary: Same-industry peer ranking with momentum classification
      parameters:
        - { name: slug, in: path, required: true, schema: { type: string } }
        - { name: n, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 25, default: 10 } }
      responses:
        '200': { $ref: '#/components/responses/GenericData' }
        '404': { description: Company not found }

  /stats/companies/leaderboard:
    get:
      tags: [Companies]
      summary: Cross-company rankings (accelerating, AI-exposed, hiring-velocity, exec-hires)
      parameters:
        - { name: metric, in: query, required: false, schema: { type: string, enum: [accelerating, ai_exposed, hiring_velocity, exec_hires], default: accelerating } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 50, default: 20 } }
        - { name: min_jobs, in: query, required: false, schema: { type: integer, minimum: 5, default: 20 } }
      responses:
        '200': { $ref: '#/components/responses/GenericData' }

  /stats/companies:
    get:
      tags: [Companies]
      summary: Company directory (list of companies with N+ live roles)
      parameters:
        - { name: min, in: query, schema: { type: integer, default: 5 } }
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/CompanyListItem' }

  /stats/top-companies:    { get: { tags: [Stats], summary: Top companies by job count, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/categories:       { get: { tags: [Stats], summary: Jobs by category with experience-level breakdown, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/top-titles:       { get: { tags: [Stats], summary: Most common job titles (top 30), responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/title-trends:     { get: { tags: [Stats], summary: Week-over-week title demand changes, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/title-salaries:   { get: { tags: [Stats], summary: Salary percentiles per title, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/work-arrangements: { get: { tags: [Stats], summary: Remote/hybrid/on-site distribution, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/experience-levels: { get: { tags: [Stats], summary: Entry/mid/senior/executive distribution, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/salary-by-sector: { get: { tags: [Stats], summary: Salary min/max/median by category, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/skills-matrix:    { get: { tags: [Stats], summary: Top skills with category split, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/skills-split:     { get: { tags: [Stats], summary: Required vs preferred skill counts, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/hiring-velocity:  { get: { tags: [Stats], summary: 30-day hiring trend timeseries, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/hiring-intensity: { get: { tags: [Stats], summary: Open-jobs-per-employee ratio for top employers, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/salary-transparency: { get: { tags: [Stats], summary: Percentage of jobs disclosing salary per category, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/market-pulse:     { get: { tags: [Stats], summary: Category growth, remote-by-industry, experience distribution, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/market-concentration: { get: { tags: [Stats], summary: Top-company share of total jobs per category, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/industry-momentum: { get: { tags: [Stats], summary: Industry hiring momentum with accelerating/stable/decelerating signal, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/titles-list:      { get: { tags: [Stats], summary: All titles with ≥3 jobs (programmatic SEO feed), responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/by-title/{title}:
    get:
      tags: [Stats]
      summary: Deep stats for a single title (companies, skills, salary, locations)
      parameters:
        - { name: title, in: path, required: true, schema: { type: string } }
      responses: { '200': { $ref: '#/components/responses/GenericData' } }
  /stats/occupation-ai-exposure: { get: { tags: [Stats], summary: SOC occupation AI exposure scores (from Anthropic telemetry), responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/skill-ai-exposure:      { get: { tags: [Stats], summary: Skill-level AI exposure scores, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }
  /stats/employer-showcase: { get: { tags: [Stats], summary: Rotating employer showcase for the homepage, responses: { '200': { $ref: '#/components/responses/GenericData' } } } }

  /api/feeds:
    get:
      tags: [Feeds (authenticated)]
      summary: List your feeds
      security: [{ apiKey: [] }]
      parameters:
        - { name: include, in: query, schema: { type: string, enum: [stats] } }
      responses: { '200': { description: OK } }
    post:
      tags: [Feeds (authenticated)]
      summary: Create a feed
      security: [{ apiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, careersUrl]
              properties:
                name: { type: string, maxLength: 100 }
                careersUrl: { type: string, format: uri }
                exampleJobUrl: { type: string, format: uri }
      responses: { '201': { description: Created } }
  /api/feeds/{id}:
    get:    { tags: [Feeds (authenticated)], summary: Feed detail, security: [{ apiKey: [] }], parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
    patch:  { tags: [Feeds (authenticated)], summary: Update feed, security: [{ apiKey: [] }], parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
    delete: { tags: [Feeds (authenticated)], summary: Delete feed + jobs, security: [{ apiKey: [] }], parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /api/feeds/{id}/run:
    post:   { tags: [Feeds (authenticated)], summary: Trigger discovery+scrape+enrich, security: [{ apiKey: [] }], parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: Queued }, '402': { description: Monthly quota exceeded }, '429': { description: 'Feed ran within the last hour (manual-run cooldown; Retry-After + retryAfterSeconds say when to retry)' } } }
  /api/feeds/{id}/jobs:
    get:    { tags: [Feeds (authenticated)], summary: Paginated jobs for a feed, security: [{ apiKey: [] }], parameters: [{ name: id, in: path, required: true, schema: { type: string } }, { name: status, in: query, schema: { type: string, enum: [discovered, scraped, enriched, failed, expired, quota_blocked] } }, { name: page, in: query, schema: { type: integer, default: 1 } }, { name: per_page, in: query, schema: { type: integer, default: 50 } }], responses: { '200': { description: OK } } }
    delete: { tags: [Feeds (authenticated)], summary: Delete all jobs for a feed, security: [{ apiKey: [] }], parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /api/feeds/{id}/xml:
    get: { tags: [Feeds (authenticated)], summary: Redirect to the public XML feed URL, security: [{ apiKey: [] }], parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '307': { description: Redirect to feeds.yubhub.co } } }
  /api/feeds/{id}/schedule:
    get:   { tags: [Feeds (authenticated)], summary: Get the feed auto-refresh schedule, security: [{ apiKey: [] }], parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
    patch: { tags: [Feeds (authenticated)], summary: Update the feed auto-refresh schedule, security: [{ apiKey: [] }], parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /api/feeds/{id}/jobs/retry-failed:
    post: { tags: [Feeds (authenticated)], summary: Re-queue this feed's failed jobs for enrichment, security: [{ apiKey: [] }], parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /api/feeds/{id}/jobs/reset-enrichment:
    post: { tags: [Feeds (authenticated)], summary: Reset this feed's jobs to re-run enrichment, security: [{ apiKey: [] }], parameters: [{ name: id, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /api/jobs/{jobId}:
    get: { tags: [Jobs], summary: Authenticated full-data job fetch, security: [{ apiKey: [] }], parameters: [{ name: jobId, in: path, required: true, schema: { type: string } }], responses: { '200': { description: OK } } }
  /api/account:
    get:    { tags: [Account (authenticated)], summary: Account profile + usage, security: [{ apiKey: [] }], responses: { '200': { description: OK } } }
    delete: { tags: [Account (authenticated)], summary: Delete account, security: [{ apiKey: [] }], responses: { '200': { description: OK } } }
  /api/account/usage:
    get: { tags: [Account (authenticated)], summary: Current plan usage and monthly quota, security: [{ apiKey: [] }], responses: { '200': { description: OK } } }
  /api/account/api-key:
    post:   { tags: [Account (authenticated)], summary: Generate a new yh_ API key, security: [{ apiKey: [] }], responses: { '200': { description: OK } } }
    delete: { tags: [Account (authenticated)], summary: Revoke current API key, security: [{ apiKey: [] }], responses: { '200': { description: OK } } }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: yh_*
      description: Prefix your API key with 'yh_' and send as Bearer token.
  schemas:
    FacetEntry:
      type: object
      properties:
        value: { type: string }
        display: { type: string }
        count: { type: integer }
    CompanyListItem:
      type: object
      properties:
        name: { type: string }
        slug: { type: string }
        logo_url: { type: string, nullable: true }
        industry: { type: string, nullable: true }
        hq_location: { type: string, nullable: true }
        founded: { type: string, nullable: true }
        stock_ticker: { type: string, nullable: true }
        github_org: { type: string, nullable: true }
        employee_count: { type: integer, nullable: true }
        job_count: { type: integer }
    JobSummary:
      type: object
      properties:
        id: { type: string }
        title: { type: string }
        company: { type: string, nullable: true }
        location: { type: string, nullable: true }
        salary_range: { type: string, nullable: true }
        salary_min: { type: number, nullable: true, description: 'Structured minimum pay in major currency units (keeps decimals, e.g. 45.67 for hourly). Drives JobPosting baseSalary.' }
        salary_max: { type: number, nullable: true, description: 'Structured maximum pay in major currency units. Null when the posting states a single figure.' }
        salary_currency: { type: string, nullable: true, description: 'ISO 4217 3-letter code for salary_min/max.' }
        salary_period: { type: string, nullable: true, enum: [year, month, week, day, hour], description: 'Pay period for salary_min/max; maps to JobPosting baseSalary.value.unitText.' }
        work_arrangement: { type: string, nullable: true }
        experience_level: { type: string, nullable: true }
        category: { type: string, nullable: true }
        industry: { type: string, nullable: true }
        source_url: { type: string, format: uri }
        enriched_at: { type: integer, format: int64 }
        company_logo_url: { type: string, nullable: true }
    JobDetail:
      allOf:
        - $ref: '#/components/schemas/JobSummary'
        - type: object
          properties:
            description: { type: string, description: 'Semantic HTML — <h3>/<h4>/<p>/<ul>/<li>/<strong>/<em>' }
            status: { type: string, enum: [enriched, expired] }
            expired_at: { type: integer, nullable: true }
            last_seen_at: { type: integer, format: int64, nullable: true, description: 'Discovery liveness heartbeat — last time the posting was confirmed present in the source feed. is_stale trips when this (falling back to enriched_at) is older than 60 days.' }
            is_stale: { type: boolean }
            skills:
              type: object
              properties:
                required: { type: array, items: { type: string } }
                preferred: { type: array, items: { type: string } }
            company_wikidata_id: { type: string, nullable: true }
            company_wikipedia_url: { type: string, nullable: true }
            company_github_org: { type: string, nullable: true }
            company_official_website: { type: string, nullable: true }
            company_stock_ticker: { type: string, nullable: true }
            company_founded: { type: string, nullable: true }
            company_employee_count: { type: integer, nullable: true }
  responses:
    SearchResult:
      description: Search results
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: object
                properties:
                  q: { type: string }
                  count: { type: integer }
                  page: { type: integer }
                  perPage: { type: integer }
                  totalPages: { type: integer }
                  jobs: { type: array, items: { $ref: '#/components/schemas/JobSummary' } }
                  topCompanies:
                    type: array
                    items: { type: object, properties: { name: { type: string }, count: { type: integer } } }
                  topTitles:
                    type: array
                    items: { type: object, properties: { title: { type: string }, count: { type: integer } } }
    FacetList:
      description: List of facet values
      content:
        application/json:
          schema:
            type: object
            properties:
              data: { type: array, items: { $ref: '#/components/schemas/FacetEntry' } }
    FacetDetail:
      description: Paginated jobs + aggregations for a single facet value
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: object
                properties:
                  facet:
                    type: object
                    properties:
                      type: { type: string }
                      value: { type: string }
                      display: { type: string }
                  count: { type: integer }
                  page: { type: integer }
                  perPage: { type: integer }
                  totalPages: { type: integer }
                  topCompanies: { type: array, items: { type: object } }
                  topTitles:    { type: array, items: { type: object } }
                  topLocations: { type: array, items: { type: object } }
                  workArrangements: { type: array, items: { type: object } }
                  experienceLevels: { type: array, items: { type: object } }
                  categories: { type: array, items: { type: object } }
                  industries: { type: array, items: { type: object } }
                  salaryStats: { type: object, nullable: true }
                  totals:
                    type: object
                    properties:
                      companies: { type: integer }
                      titles: { type: integer }
                      locations: { type: integer }
                  jobs: { type: array, items: { $ref: '#/components/schemas/JobSummary' } }
    CompanyDetail:
      description: Company intelligence profile + hiring aggregates
      content:
        application/json:
          schema:
            type: object
            properties:
              data:
                type: object
                properties:
                  company: { type: object }
                  hiring: { type: object }
    GenericData:
      description: Stat response — shape varies per endpoint; see endpoint summary
      content:
        application/json:
          schema:
            type: object
            properties:
              data: { type: object }
    JobDetail:
      description: Full job record incl. semantic-HTML description and company linked-data
      content:
        application/json:
          schema:
            type: object
            properties:
              data: { $ref: '#/components/schemas/JobDetail' }
