openapi: 3.1.0

info:
  title: Cerulean Search API
  version: "1.0.0"
  summary: Public, unauthenticated search over the Cerulean luxury job board.
  description: |
    `GET /api/search` is the public search endpoint behind
    [ceruleanjobs.com/search/](https://ceruleanjobs.com/search/). It is the same
    endpoint the site's own search page calls, and it is open to anyone — there
    is no API key, no authentication, and no rate limit.

    Requests are answered from an in-memory index held by regional Lambdas in
    Singapore, Virginia, Oregon and Paris. Route 53 latency routing sends each
    caller to the nearest one, and there is no database in the query path.
    Median server time is a few milliseconds.

    ### Freshness

    The index is rebuilt roughly every four hours by the production pipeline, so
    results can lag the site by up to one refresh cycle. Responses are cached at
    the CDN edge for one hour.

    ### Being a good citizen

    Crawlers and scripted clients are welcome. Please send a `User-Agent` that
    identifies your project so we can contact you if something goes wrong, and
    prefer larger pages over many small ones — `limit=48` costs the same as
    `limit=1`.

  contact:
    name: Cerulean
    url: https://ceruleanjobs.com/about/
  license:
    name: Job data is provided for personal and editorial use.
    url: https://ceruleanjobs.com/about/

servers:
  - url: https://ceruleanjobs.com
    description: Primary site (English)
  - url: https://cerulean.fr
    description: French localisation — same index, French fields available via `lang=fr`

# The search endpoint is deliberately open — no key, no token, no session.
# An empty requirement list is how OpenAPI states that positively, rather than
# leaving a reader to infer it from the absence of a security scheme.
security: []

tags:
  - name: Search
    description: Full-text search, filtering, and similar-job lookup.

paths:
  /api/search:
    get:
      tags: [Search]
      summary: Search job listings
      operationId: searchJobs
      description: |
        Returns a page of job listings ranked by relevance, recency and
        semantic similarity.

        **Every parameter is optional** — there is no required parameter and no
        minimum query. With no parameters at all, the endpoint returns the 12
        most recent listings.

        ### Ranking

        A general search blends three signals: BM25 text relevance (50%), a
        Gaussian recency decay weighted by brand prestige (35%), and cosine
        similarity against a 512-dimension embedding of the query (15%).

        When `similar` is supplied the blend changes to favour proximity:
        vector similarity (50%), location closeness (35%) and recency (15%).
        Text relevance is not used.

        ### Value encoding

        Filter values are matched against a lowercased index. Send them
        lowercased, with `+` or `%20` for spaces — `location=new+york`, not
        `location=New%20York`. Values are trimmed and truncated to 200
        characters.

        ### Invalid input never fails

        The endpoint does not return 4xx. A filter value that matches nothing
        yields `total: 0`; a non-numeric `limit` or `offset` falls back to its
        default; out-of-range values are clamped.
      parameters:
        - name: query
          in: query
          description: |
            Free-text relevance search. Matched against title (boosted ×2),
            alternative titles (×1.5), description, qualifications,
            responsibilities, skills, experience, employer overview, culture
            and benefits.

            When `lang=fr`, the French analysed fields are searched instead.
          required: false
          schema:
            type: string
            maxLength: 200
          example: fashion designer
        - name: location
          in: query
          description: |
            Matches a city, region or country — whichever the value happens to
            name. A single field, not three:

            * `paris` — a city
            * `lombardy` — a region
            * `france` — a country

            Use `country` instead when you specifically mean the country.
          required: false
          schema:
            type: string
            maxLength: 200
          example: paris
        - name: country
          in: query
          description: |
            Exact country filter, used for site-wide country scoping. Narrower
            than `location`, and may be combined with it.
          required: false
          schema:
            type: string
            maxLength: 200
          example: france
        - name: brand
          in: query
          description: Exact brand slug filter.
          required: false
          schema:
            type: string
            maxLength: 200
          example: chanel
        - name: employment-type
          in: query
          description: Exact employment-type filter. One of nine canonical values.
          required: false
          schema:
            type: string
            enum:
              - full-time
              - part-time
              - contract
              - temporary
              - seasonal
              - internship
              - volunteer
              - per-diem
              - other
          example: full-time
        - name: seniority-level
          in: query
          description: Exact seniority-level filter. One of ten canonical values.
          required: false
          schema:
            type: string
            enum:
              - intern
              - entry-level
              - junior
              - mid-level
              - senior
              - lead
              - supervisor
              - manager
              - director
              - executive
          example: senior
        - name: industry
          in: query
          description: Exact industry filter. One of seventeen canonical values.
          required: false
          schema:
            type: string
            enum:
              - fashion-apparel-leather
              - watches-horology
              - high-jewelry
              - perfumes-cosmetics-beauty
              - eyewear-vision-care
              - wines-spirits-gastronomy
              - luxury-hospitality-travel
              - selective-retailing
              - luxury-real-estate
              - yachting-aviation-mobility
              - pre-owned-vintage-luxury
              - media-art-culture
              - luxury-automotive
              - luxury-furniture-homewares
              - high-end-audio-electronics
              - premium-sporting-equipment
              - photography-optics
          example: high-jewelry
        - name: department
          in: query
          description: Exact department filter. One of twenty-six canonical values.
          required: false
          schema:
            type: string
            enum:
              - retail-operations
              - wholesale-b2b
              - merchandising-buying
              - ecommerce-digital
              - clienteling-crm
              - creative-design
              - research-innovation
              - visual-merchandising-architecture
              - heritage-archives
              - manufacturing-artisanal
              - supply-chain-logistics
              - culinary-food-production
              - food-beverage-service
              - front-office-guest-experience
              - housekeeping-cleaning
              - spa-wellness
              - engineering-facilities
              - real-estate-asset-management
              - corporate-affairs-csr
              - human-resources
              - finance-accounting
              - it-technology
              - medical-clinical
              - legal-compliance
              - executive-management
              - other
          example: creative-design
        - name: similar
          in: query
          description: |
            Slug of a job to find similar listings for. Switches the endpoint
            into similarity mode: results are ranked by semantic and
            geographic proximity to that job, and the job itself is excluded
            from its own results, so `total` needs no adjustment.

            Any `brand` filter is ignored in this mode — similarity is meant to
            cross brands. If the slug is unknown or has no embedding, the
            request degrades to an ordinary recency-ranked search rather than
            failing.
          required: false
          schema:
            type: string
            maxLength: 200
          example: senior-atelier-manager-chanel-paris
        - name: lang
          in: query
          description: |
            Set to `fr` to search French analysed fields and return French
            `title` and `description` where a translation exists. Any other
            value, or omitting it, uses English.

            Falls back to the English text per field when a translation is
            missing, so a French response may mix languages.
          required: false
          schema:
            type: string
            enum: [en, fr]
            default: en
          example: fr
        - name: limit
          in: query
          description: |
            Results per page. Optional — omit it and you get 12, the same page
            size the site's own search uses.

            Values above the maximum are clamped rather than rejected; zero,
            negative and non-numeric values fall back to that default of 12.
          required: false
          schema:
            type: integer
            format: int32
            minimum: 1
            maximum: 48
            default: 12
          example: 24
        - name: offset
          in: query
          description: |
            Results to skip. Optional — omit it and you start at the first
            result.

            Pagination is native to the index, so deep offsets cost no more than
            shallow ones. Values above the maximum are clamped; negative values
            become 0.
          required: false
          schema:
            type: integer
            format: int32
            minimum: 0
            maximum: 10000
            default: 0
          example: 24
      responses:
        "200":
          description: |
            A page of results. Returned for every well-formed request,
            including one that matches nothing.
          headers:
            Cache-Control:
              description: Edge caching directive.
              schema:
                type: string
                examples:
                  - public, max-age=0, s-maxage=3600, stale-while-revalidate=600
            X-Robots-Tag:
              description: Always `noindex` — the JSON itself should not be indexed.
              schema:
                type: string
                examples:
                  - noindex
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchResponse"
              examples:
                textSearch:
                  summary: Text search with a filter
                  description: >-
                    `/api/search?query=atelier&location=paris&limit=2`
                  value:
                    results:
                      - title: Atelier Manager
                        slug: atelier-manager-chanel-paris
                        url: /jobs/atelier-manager-chanel-paris/
                        description: >-
                          Lead the couture atelier, overseeing a team of
                          premières and seamstresses through each collection.
                        city: Paris
                        region: Île-de-France
                        country: France
                        datePosted: "2026-08-14T09:12:00Z"
                        paintingUrl: /images/paintings/atelier-manager.webp
                        brand:
                          slug: chanel
                          name: Chanel
                          hasLogo: true
                        employmentType: Full-time
                        seniorityLevel: Senior
                        industry: Fashion
                        department: Production
                      - title: Première d'Atelier
                        slug: premiere-datelier-dior-paris
                        url: /jobs/premiere-datelier-dior-paris/
                        description: >-
                          Supervise the construction of haute couture garments
                          from first toile to final fitting.
                        city: Paris
                        region: Île-de-France
                        country: France
                        datePosted: "2026-08-13T16:40:00Z"
                        paintingUrl: /images/paintings/premiere-datelier.webp
                        brand:
                          slug: dior
                          name: Dior
                          hasLogo: true
                        employmentType: Full-time
                        seniorityLevel: Mid
                        industry: Fashion
                        department: Production
                    total: 37
                    hasMore: true
                    limit: 2
                    offset: 0
                noMatches:
                  summary: A filter that matches nothing
                  description: >-
                    `/api/search?brand=not-a-real-brand` — still a 200.
                  value:
                    results: []
                    total: 0
                    hasMore: false
                    limit: 12
                    offset: 0

components:
  schemas:
    SearchResponse:
      type: object
      description: One page of search results.
      required: [results, total, hasMore, limit, offset]
      properties:
        results:
          type: array
          description: The requested page, in ranked order. Empty when nothing matches.
          items:
            $ref: "#/components/schemas/SearchResult"
        total:
          type: integer
          format: int64
          minimum: 0
          description: >-
            Total listings matching the query across all pages, not the length
            of `results`.
          examples: [11545]
        hasMore:
          type: boolean
          description: Whether any results remain after this page.
          examples: [true]
        limit:
          type: integer
          format: int32
          description: The effective page size, after clamping.
          examples: [12]
        offset:
          type: integer
          format: int32
          description: The effective offset, after clamping.
          examples: [0]

    SearchResult:
      type: object
      description: A single job listing.
      required:
        - title
        - slug
        - url
        - city
        - country
        - datePosted
        - paintingUrl
        - brand
        - region
        - employmentType
        - seniorityLevel
        - industry
        - department
      properties:
        title:
          type: string
          description: Job title. French when `lang=fr` and a translation exists.
          examples: [Atelier Manager]
        slug:
          type: string
          description: Stable identifier for the listing. Pass to `similar`.
          examples: [atelier-manager-chanel-paris]
        url:
          type: string
          description: |
            Path to the listing on ceruleanjobs.com. Always the English
            `/jobs/{slug}/` form, including on cerulean.fr — the French site
            rewrites it to `/emplois/{slug}/` in the browser. Prepend a host to
            get an absolute URL.
          examples: [/jobs/atelier-manager-chanel-paris/]
        description:
          type: string
          maxLength: 300
          description: >-
            Plain-text excerpt, truncated to 300 characters. Omitted when the
            listing has no description.
          examples:
            - Lead the couture atelier through each collection.
        city:
          type: string
          description: Title-cased city. Empty string when unknown.
          examples: [Paris]
        region:
          type: string
          description: Title-cased region or state. Empty string when unknown.
          examples: [Île-de-France]
        country:
          type: string
          description: Title-cased country. Empty string when unknown.
          examples: [France]
        datePosted:
          type: string
          format: date-time
          description: When the listing was first collected, RFC 3339 in UTC.
          examples: ["2026-08-14T09:12:00Z"]
        paintingUrl:
          type: string
          description: >-
            Path to the generated illustration for the listing. Empty string
            when none exists.
          examples: [/images/paintings/atelier-manager.webp]
        brand:
          $ref: "#/components/schemas/Brand"
        employmentType:
          type: string
          description: Employment type. Empty string when unclassified.
          examples: [Full-time]
        seniorityLevel:
          type: string
          description: Seniority level. Empty string when unclassified.
          examples: [Senior]
        industry:
          type: string
          description: Industry. Empty string when unclassified.
          examples: [Fashion]
        department:
          type: string
          description: Department. Empty string when unclassified.
          examples: [Production]

    Brand:
      type: object
      description: The hiring brand.
      required: [slug, name, hasLogo]
      properties:
        slug:
          type: string
          description: Brand identifier. Pass to the `brand` filter.
          examples: [chanel]
        name:
          type: string
          description: Display name.
          examples: [Chanel]
        hasLogo:
          type: boolean
          description: >-
            Whether a logo exists at
            `/images/logos/{slug}.webp` on ceruleanjobs.com.
          examples: [true]
