openapi: 3.1.0
info:
  title: 4dayweek.io Public API
  description: |
    Free, public, no-auth JSON API for every live job on 4dayweek.io.

    Two versions are available:
    - **v1** (`/api/v1`) — legacy-compat shape, kept for backwards compatibility
      with the original `/api` endpoint. Paginated.
    - **v2** (`/api/v2`) — modern, richer, paginated, filterable. Recommended for
      any new integration.

    ### Rate limits
    60 requests per minute per IP. Over the limit returns HTTP 429 with a
    `Retry-After` header.

    ### Caching
    Responses are cached in-process for 60 seconds and served with
    `Cache-Control: public, max-age=60`. An `X-Cache: HIT|MISS|BYPASS` header
    is emitted for observability.

    ### Link-back
    Please credit 4dayweek.io if you use this API in a public product.
  version: "1.0.0"
  contact:
    name: 4dayweek.io
    url: https://4dayweek.io/contact
  license:
    name: Free for public use; link-back requested.

servers:
  - url: https://4dayweek.io
    description: Production

tags:
  - name: v1
    description: Legacy-compatibility endpoints (mirrors the original /api shape).
  - name: v2
    description: Modern paginated, filterable endpoints.

paths:
  /api/v1:
    get:
      tags: [v1]
      summary: List live jobs (legacy shape)
      description: |
        Paginated list of live jobs in the legacy field shape. Only `page` and
        `limit` are honored — all other filters are ignored by design so the v1
        contract stays minimal. For filtering, use v2.
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: Successful response
          headers:
            X-API-Version:
              schema: { type: string, example: v1 }
            Cache-Control:
              schema: { type: string, example: "public, max-age=60" }
            X-Cache:
              schema: { type: string, example: HIT }
            X-RateLimit-Limit:
              schema: { type: integer, example: 60 }
            X-RateLimit-Remaining:
              schema: { type: integer, example: 58 }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LegacyListResponse'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v2/jobs:
    get:
      tags: [v2]
      summary: List live jobs (modern shape)
      description: Paginated, filterable job listings.
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: category
          in: query
          description: Comma-separated list of categories (e.g. engineering,sales).
          schema: { type: string }
        - name: level
          in: query
          description: Comma-separated list of levels (e.g. senior,lead).
          schema: { type: string }
        - name: schedule
          in: query
          description: Comma-separated schedule types (e.g. 4_day_week,9_day_fortnight).
          schema: { type: string }
        - name: work_arrangement
          in: query
          description: Comma-separated work arrangements (onsite, hybrid, remote).
          schema: { type: string }
        - name: skills
          in: query
          description: Comma-separated skill slugs (e.g. python,go).
          schema: { type: string }
        - name: country
          in: query
          description: Country name — matches office or remote-allowed locations.
          schema: { type: string }
        - name: salary_min
          in: query
          description: Minimum salary in USD (whole dollars).
          schema: { type: integer, minimum: 0 }
        - name: salary_max
          in: query
          description: Maximum salary in USD (whole dollars).
          schema: { type: integer, minimum: 0 }
        - name: posted_after
          in: query
          description: Posted within the last N days. Capped at 365.
          schema: { type: integer, minimum: 0, maximum: 365 }
        - name: q
          in: query
          description: Full-text search.
          schema: { type: string }
        - name: sort
          in: query
          schema:
            type: string
            enum: [date, salary]
            default: date
      responses:
        '200':
          description: Successful response
          headers:
            X-API-Version:
              schema: { type: string, example: v2 }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/V2ListResponse'
        '400':
          description: Invalid query parameter (e.g. bad sort value).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v2/jobs/{slug}:
    get:
      tags: [v2]
      summary: Get a single job by slug (modern shape)
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/V2Job'
        '404':
          description: Job not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  parameters:
    PageParam:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    LimitParam:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25

  responses:
    RateLimited:
      description: Rate limit exceeded (60 req/min per IP).
      headers:
        Retry-After:
          schema: { type: integer }
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  schemas:
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: integer
              example: 400
            message:
              type: string
              example: invalid sort
            field:
              type: string

    # --------------------------------------------------------------------- #
    # v1 legacy schemas
    # --------------------------------------------------------------------- #

    LegacyListResponse:
      type: object
      required: [usage, page, total, has_more, jobs]
      properties:
        usage:
          type: string
          example: "Thanks for using this API! ..."
        page:
          type: integer
          example: 1
        total:
          type: integer
          example: 2341
        has_more:
          type: boolean
        jobs:
          type: array
          items:
            $ref: '#/components/schemas/LegacyJob'

    LegacyJob:
      type: object
      required:
        - id_str
        - title
        - slug
        - url
        - description
        - category
        - role
        - level
        - hours
        - reduced_hours
        - is_remote
        - posted
        - filters
        - company_id
        - company_name
        - company
      properties:
        id_str:
          type: string
          description: |
            Job ID as a UUID string. Old consumers that parsed this as an int
            must switch to string. The trailing `_str` in the field name
            indicates this was always the intended type.
        title: { type: string }
        slug: { type: string }
        url:
          type: string
          format: uri
          description: Canonical public URL for the job page.
        description:
          type: string
          description: Plain-text description. HTML is never exposed.
        category:
          type: string
          description: Human-readable label (e.g. "Engineering", "Data Science").
        role: { type: string }
        level:
          type: string
          description: Human-readable label (e.g. "Senior", "Mid-level", "Junior").
        hours: { type: integer, example: 32 }
        reduced_hours:
          type: string
          description: |
            Schedule description (e.g. "Offered", "Generous PTO", "9 day fortnight").
          example: Offered
        is_remote:
          type: boolean
          description: Derived from work_arrangement != 'onsite'.
        location_city: { type: string }
        location_country: { type: string }
        location_continent: { type: string }
        location_original:
          type: string
          example: Remote in Riyadh (Saudi Arabia)
        posted:
          type: integer
          format: int64
          description: Unix epoch seconds.
          example: 1736640000
        filters:
          type: array
          description: |
            Union of skills + stack + tools, deduplicated by slug, preserving
            the order skills → stack → tools.
          items:
            $ref: '#/components/schemas/LegacyTag'
        company_id: { type: string }
        company_name:
          type: string
          description: Legacy identifier — typically the company slug.
        company:
          $ref: '#/components/schemas/LegacyCompany'

    LegacyTag:
      type: object
      required: [label, value]
      properties:
        label: { type: string, example: Python }
        value: { type: string, example: python }

    LegacyCompany:
      type: object
      properties:
        id_str: { type: string }
        name: { type: string, example: Lucidya }
        slug: { type: string, example: lucidya }
        url:
          type: string
          format: uri
          description: Includes /jobs suffix (e.g. /company/acme/jobs).
        category: { type: string }
        short_description: { type: string }
        description:
          type: string
          description: Plain-text description.
        country: { type: string }
        employees:
          type: integer
          format: int64
        logo_url:
          type: string
          format: uri
        company_url:
          type: string
          format: uri
        reduced_hours:
          type: string
          description: Descriptive schedule text (e.g. "4 day week @ 100% salary").
          example: 4 day week @ 100% salary
        four_day_reference_text:
          type: string
          description: Company's schedule policy description.
        images:
          type: array
          items: { type: string, format: uri }
        remote_level:
          type: string
          description: Mapped to old format ("100% remote", "Offers remote", "Onsite").
          example: 100% remote

    # --------------------------------------------------------------------- #
    # v2 modern schemas
    # --------------------------------------------------------------------- #

    V2ListResponse:
      type: object
      required: [data, page, limit, total, has_more]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/V2Job'
        page: { type: integer }
        limit: { type: integer }
        total: { type: integer }
        has_more: { type: boolean }

    V2Job:
      type: object
      required: [id, slug, title, url, work_arrangement, is_remote, posted_at]
      properties:
        id: { type: string, description: UUID string }
        slug: { type: string }
        title: { type: string }
        description:
          type: string
          description: Plain text only.
        url: { type: string, format: uri }
        # apply_url removed — use the job URL on 4dayweek.io instead
        category: { type: string }
        role: { type: string }
        level: { type: string }
        contract_type:
          type: string
          enum: [permanent, contract, freelance]
        schedule_type:
          type: string
          example: 4_day_week
        hours_per_week_min: { type: integer }
        hours_per_week_max: { type: integer }
        work_arrangement:
          type: string
          enum: [onsite, hybrid, remote]
        is_remote: { type: boolean }
        office_locations:
          type: array
          items:
            $ref: '#/components/schemas/V2JobLocation'
        remote_allowed:
          type: array
          items:
            $ref: '#/components/schemas/V2JobLocation'
        timezones:
          type: array
          items: { type: string }
        salary_min:
          type: integer
          format: int64
          description: |
            In smallest currency unit (e.g. cents for USD). 8000000 = $80,000.
            Note: the salary_min/salary_max query *filters* accept whole dollars
            (e.g. 80000), but *response* values are in cents.
        salary_max:
          type: integer
          format: int64
        salary_currency: { type: string, example: USD }
        salary_period:
          type: string
          enum: [year, month, hour]
        skills:
          type: array
          items:
            $ref: '#/components/schemas/V2Tag'
        stack:
          type: array
          items:
            $ref: '#/components/schemas/V2Tag'
        tools:
          type: array
          items:
            $ref: '#/components/schemas/V2Tag'
        posted_at:
          type: string
          format: date-time
          description: RFC3339 UTC timestamp.
        expires_at:
          type: string
          format: date-time
          description: RFC3339 UTC timestamp. Omitted when unknown.
        company:
          $ref: '#/components/schemas/V2Company'

    V2Tag:
      type: object
      required: [name, slug]
      properties:
        name: { type: string }
        slug: { type: string }

    V2JobLocation:
      type: object
      properties:
        city: { type: string }
        state: { type: string }
        country: { type: string }
        continent: { type: string }
        region: { type: string }

    V2Company:
      type: object
      required: [id, slug, name, url]
      properties:
        id: { type: string }
        slug: { type: string }
        name: { type: string }
        url: { type: string, format: uri }
        website: { type: string, format: uri }
        logo_url: { type: string, format: uri }
        short_description: { type: string }
        country: { type: string }
        employees:
          type: integer
          format: int64
        schedule_type: { type: string }
        hours_full_time:
          type: integer
          format: int64
        remote_level: { type: string }
        work_life_score: { type: integer }
