openapi: 3.0.3
info:
  title: Jobo Enterprise API
  description: |
    Search and stream job listings, look up company profiles, geocode locations and read the occupation taxonomy.

    Auto Apply is coming soon. Build against the [sandbox](/sandbox) today with a `jbe_test_…` key, no approval needed. Live applications need Jobo to approve your business: request it in the dashboard under **Auto Apply → Getting started → Request live access**.
  version: "2026-08-31"
  contact:
    name: Jobo Support
    url: https://jobo.world
    email: support@jobo.world
  license:
    name: Proprietary — see terms of service
    url: https://jobo.world/terms
  termsOfService: https://jobo.world/terms

servers:
  - url: https://connect.jobo.world
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: Jobs
    description: Search and retrieve job listings
  - name: Feed
    description: Bulk job feeds and expiration tracking
  - name: Companies
    description: Company profiles and company-scoped job listings
  - name: Auto Apply
    description: "Submit job applications through Jobo: a blocking create returns the form's typed fields, answers are validated and filled synchronously, and the call returns the next step or the result."
  - name: Locations
    description: Geocoding and location services
  - name: Occupations
    description: The occupation taxonomy jobs are classified into

paths:
  # ──────────────────────────────────────────────
  #  Jobs Search
  # ──────────────────────────────────────────────
  /api/jobs:
    get:
      operationId: searchJobsGet
      x-codeSamples:
        - lang: cURL
          source: |
            curl -G "https://connect.jobo.world/api/jobs" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              --data-urlencode "q=software engineer" \
              --data-urlencode "work_model=remote" \
              --data-urlencode "page_size=25"
        - lang: Python
          source: |
            import os

            from jobo_enterprise import JoboClient

            with JoboClient(api_key=os.environ["JOBO_API_KEY"]) as client:
                results = client.search.search(
                    q="software engineer",
                    work_model="remote",
                    page_size=25,
                )
                print(results.total)
                for job in results.jobs:
                    print(job.title, job.company.name)
      summary: List jobs
      description: |
        Search active jobs with query-string filters. Filters take lowercase canonical keys (`full-time`), not the display values jobs return (`Full-time`); see [Enums](/api-reference/jobs/object#enums). For several queries or locations, or include/exclude filters, use [`POST /api/jobs/search`](/api-reference/jobs/search-jobs).

        Billed per delivered job: included jobs first, then the wallet. The Unlimited plan does not cover Search. See [Billing](/billing).
      x-mint:
        metadata:
          sidebarTitle: Quick search
      tags: [Jobs]
      parameters:
        - name: q
          in: query
          schema:
            type: string
          description: |-
            Free-text search query. By default it matches broadly against the job title, company name, popular skills, and summary with typo tolerance. Wrap the value in double quotes (e.g. `q="Quantitative Developer"`) for an exact, contiguous phrase match restricted to the job title only.
        - name: search_description
          in: query
          schema:
            type: boolean
            default: true
          description: |
            When true, search title, alternative titles, company, popular skills, and summary. When false, restrict matching to titles and curated alternative titles. A double-quoted query always matches an exact contiguous phrase in the literal title.
        - name: location
          in: query
          schema:
            type: string
          description: Location filter (city, region, or country)
        - name: sources
          in: query
          schema:
            type: string
          description: Comma-separated source identifiers (ATS slugs such as `greenhouse`). [Sources](/sources) lists every value.
        - name: work_model
          in: query
          schema:
            type: string
          description: Comma-separated work models (remote, hybrid, onsite)
        - name: employment_type
          in: query
          schema:
            type: string
          description: Comma-separated employment types (full-time, part-time, contract, internship, freelance, temporary)
        - name: experience_level
          in: query
          schema:
            type: string
          description: |
            Comma-separated experience levels. Canonical values: `intern`, `entry`, `mid`, `senior`, `lead`, `executive`.
        - name: language
          in: query
          schema:
            type: string
          description: Comma-separated ISO 639-1 codes of the description language (e.g. `en,de`).
        - name: posted_after
          in: query
          schema:
            type: string
            format: date-time
          description: Only return jobs whose employer posting date is on or after this date
        - name: posted_before
          in: query
          schema:
            type: string
            format: date-time
          description: Only return jobs whose employer posting date is on or before this date
        - name: min_salary_usd
          in: query
          schema:
            type: integer
            minimum: 0
          description: Minimum annual salary in USD
        - name: max_salary_usd
          in: query
          schema:
            type: integer
            minimum: 0
          description: Maximum annual salary in USD
        - name: skills
          in: query
          schema:
            type: string
          description: Comma-separated skill names
        - name: industries
          in: query
          schema:
            type: string
          description: Comma-separated company-industry names. A job matches any supplied value.
        - name: company_categories
          in: query
          schema:
            type: string
          description: |-
            Comma-separated company business-model categories (`b2b`, `b2c`, `b2b2c`, `saas`, `marketplace`, `service-provider`). A job matches any supplied value.
        - name: job_categories
          in: query
          schema:
            type: string
          description: |-
            Comma-separated occupation categories (the job's `occupation.category`, e.g. `Healthcare`). A job matches any supplied value. Exact match, ignoring case. `GET /api/occupations` lists every value. Jobs without an `occupation` never match.
        - name: job_subcategories
          in: query
          schema:
            type: string
          description: |-
            Comma-separated occupation subcategories (the job's `occupation.subcategory`, e.g. `Nursing`). A job matches any supplied value. Exact match, ignoring case. `GET /api/occupations` lists every value. Four subcategory names contain a comma (e.g. `Film, TV & Stage Production`) and are split here, so they match nothing; filter those with `POST /api/jobs/search`.
        - name: occupation_ids
          in: query
          schema:
            type: string
          description: |-
            Comma-separated occupation ids (the job's `occupation.id`, e.g. `esco:8d3e8aaa-791b-4c75-a465-f3f827028f50`). A job matches any supplied value. Use ids, not names: names can improve between taxonomy releases, ids don't. `GET /api/occupations` lists every id. To exclude categories, subcategories or occupations, use `POST /api/jobs/search`.
        - name: discovered_after
          in: query
          schema:
            type: string
            format: date-time
          description: Only return jobs first indexed on or after this UTC timestamp
        - name: discovered_before
          in: query
          schema:
            type: string
            format: date-time
          description: Only return jobs first indexed on or before this UTC timestamp
        - name: sort
          in: query
          schema:
            type: string
            enum: [relevance, discovered_asc, discovered_desc]
            default: relevance
          description: |
            Result order. `relevance` (default) ranks by match quality, or by most recently updated without `q`; it shifts as jobs update, so paging can skip or repeat a job. `discovered_asc` and `discovered_desc` order by `created_at` (first indexed), which never changes: pair them with a `discovered_after` / `discovered_before` window for stable pages. See [Pagination](/pagination#stable-pages-over-a-fixed-window).
        - name: page
          in: query
          schema:
            type: integer
            default: 1
            minimum: 1
          description: Page number (1-indexed)
        - name: page_size
          in: query
          schema:
            type: integer
            default: 25
            minimum: 1
            maximum: 100
          description: Number of results per page (max 100)
        - name: description_format
          in: query
          schema:
            type: string
            enum: [markdown, html]
            default: markdown
          description: |
            Format of each job's `description`. `markdown` (default) returns it as stored. `html` renders it to HTML on the fly: raw HTML in the source is escaped and only `http(s)`, `mailto` and relative URLs are kept.
        - name: include_fields
          in: query
          schema:
            type: string
          description: |
            Comma-separated heavy fields to return: `description`, `summary`, `qualifications`, `responsibilities`, `benefits`. Core fields (id, title, company, locations, compensation, dates, …) always come back.

            Omit it for the full job (the default), pass a subset to keep only those heavy fields, or pass an empty value (`?include_fields=`) for core fields only. Names are case-insensitive; unknown names are ignored. An excluded `description` is `""`, `summary` is `null`, `qualifications` has empty buckets, and `responsibilities` and `benefits` are `[]`.
        - name: include_facets
          in: query
          schema:
            type: string
          description: |
            Comma-separated facets to compute: `work_model`, `experience_level`, `employment_type`, `sources`, `industries`, `skills`, `countries`, `company_categories`, `job_categories`, `job_subcategories`, `occupations`. Omit it for `work_model`, `experience_level`, `employment_type` and `sources`; pass an empty value for none. Occupation facet buckets carry `name` and `parent`, so you can nest category › subcategory › occupation.
      responses:
        "200":
          description: Paginated list of matching jobs
          headers:
            X-Total-Count:
              schema: { type: integer, format: int64 }
              description: Total number of matching jobs across all pages.
            X-Total-Pages:
              schema: { type: integer }
              description: Total number of pages available.
            X-Page:
              schema: { type: integer }
              description: Current page number.
            X-Page-Size:
              schema: { type: integer }
              description: Page size used for this request.
            X-Credits-Deducted:
              schema: { type: integer }
              description: |-
                Credits charged to the wallet for this response. `0` when included jobs cover every delivered job; absent on an empty page.
            X-Credits-Balance:
              schema: { type: integer }
              description: Your wallet balance in credits after the charge. Omitted when nothing was charged.
            X-Billing-Status:
              schema: { type: string, enum: [Failed, Unconfirmed] }
              description: Rare best-effort wallet outcome. Do not automatically replay an Unconfirmed delivery.
            X-Quota-Limit:
              schema: { type: integer }
              description: Your plan's total included jobs across Search, Feed, Export, and Outbound.
            X-Quota-Remaining:
              schema: { type: integer }
              description: Included jobs remaining after this response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobSearchResponse"
        "400":
          description: Invalid query parameters
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/SearchUnavailable"

  /api/jobs/search:
    post:
      operationId: searchJobsPost
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST "https://connect.jobo.world/api/jobs/search" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{
                "queries": ["backend engineer"],
                "locations": ["London"],
                "work_models": ["remote", "hybrid"],
                "experience_levels": ["senior"],
                "salary_usd": { "min": 150000 },
                "page_size": 50
              }'
        - lang: Python
          source: |
            import os

            from jobo_enterprise import JoboClient, RangeFilter

            with JoboClient(api_key=os.environ["JOBO_API_KEY"]) as client:
                results = client.search.search_advanced(
                    queries=["backend engineer"],
                    locations=["London"],
                    work_models=["remote", "hybrid"],
                    experience_levels=["senior"],
                    salary_usd=RangeFilter(min=150_000),
                    page_size=50,
                )
      summary: Search jobs
      description: |
        Structured search: several queries and locations (OR'd), include/exclude filters, date and salary ranges, field selection and facets. The body is optional; without one, you get the first page of an unfiltered search. [Search recipes](/guides/search-recipes) has worked examples, and the public [Search Builder page](https://jobo.world/api/search-builder) writes this request body for you and shows how many jobs it matches.

        Billed per delivered job: included jobs first, then the wallet. The Unlimited plan does not cover Search. See [Billing](/billing).
      x-mint:
        metadata:
          sidebarTitle: Advanced search
      tags: [Jobs]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JobSearchBodyRequest"
      responses:
        "200":
          description: Paginated list of matching jobs with optional facets
          headers:
            X-Total-Count:
              schema: { type: integer, format: int64 }
            X-Total-Pages:
              schema: { type: integer }
            X-Page:
              schema: { type: integer }
            X-Page-Size:
              schema: { type: integer }
            X-Credits-Deducted:
              schema: { type: integer }
              description: |-
                Credits charged to the wallet for this response. `0` when included jobs cover every delivered job; absent on an empty page.
            X-Credits-Balance:
              schema: { type: integer }
              description: Your wallet balance in credits after the charge. Omitted when nothing was charged.
            X-Billing-Status:
              schema: { type: string, enum: [Failed, Unconfirmed] }
              description: Rare best-effort wallet outcome. Do not automatically replay an Unconfirmed delivery.
            X-Quota-Limit:
              schema: { type: integer }
              description: Your plan's total included jobs across Search, Feed, Export, and Outbound.
            X-Quota-Remaining:
              schema: { type: integer }
              description: Included jobs remaining after this response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobSearchResponse"
        "400":
          description: Invalid request body
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/SearchUnavailable"

  # ──────────────────────────────────────────────
  #  Feed API
  # ──────────────────────────────────────────────
  /api/jobs/feed:
    post:
      operationId: getJobFeed
      x-codeSamples:
        - lang: cURL
          source: |
            # First request carries the filters and batch size.
            curl -X POST "https://connect.jobo.world/api/jobs/feed" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"sources": ["greenhouse"], "batch_size": 1000}'

            # Continuation requests carry ONLY the cursor.
            curl -X POST "https://connect.jobo.world/api/jobs/feed" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"cursor": "eyJ2IjoxLCJ..."}'
        - lang: Python
          source: |
            import os

            from jobo_enterprise import JoboClient

            with JoboClient(api_key=os.environ["JOBO_API_KEY"]) as client:
                # iter_jobs walks the cursor for you.
                for job in client.feed.iter_jobs(sources=["greenhouse"], batch_size=1000):
                    save_to_database(job)
      summary: Stream the job feed
      description: |
        Stream active jobs in batches of up to 1,000, oldest first by `created_at` (the default `stable_scan: true`), so jobs updated or expired mid-scan are never skipped or repeated.

        Send filters and `batch_size` on the first request only. After that, send only the previous `next_cursor` (and `description_format` if you use it: every request reads it), one request at a time, until `has_more` is `false`. Filters sent with a cursor are ignored, and `batch_size` can be lowered but not raised. Persist the cursor after each batch so a crash can resume; see [Pagination](/pagination#cursor-based-pagination-for-feeds).

        Filters combine with AND. The occupation and company filters take `{"include": [...], "exclude": [...]}`; `sources`, `work_models`, `employment_types`, `experience_levels` and `languages` each have an `exclude_` list. A job or company with no value never matches an `include` and is kept by an `exclude`. [Saved feeds](/guides/sync-a-database#saved-feeds) maps every Feed Builder condition to these fields. The public [Feed Builder page](https://jobo.world/api/feed-builder) writes this request body for you and shows how many jobs it matches.

        After the backfill, sync changes with `updated_after` and removals with [`GET /api/jobs/expired`](/api-reference/feed/list-expired-jobs); see [Sync a database](/guides/sync-a-database). For a saved feed or your managed job sources, use [`POST /api/jobs/feed/{feed_id}`](/api-reference/feed/stream-a-saved-feed) or [`POST /api/jobs/feed/managed`](/api-reference/feed/stream-the-managed-feed).

        Set a response timeout of at least 120 seconds. Retry only `429`, `500` and `503`, after `Retry-After`; on `400 invalid_cursor` or `409 feed_cursor_restart_required`, discard the cursor and start a new scan with `stable_scan: true` ([Retry strategy](/errors#retry-strategy)).

        Billed per delivered job: included jobs first, then the wallet. The Unlimited plan has no per-job charge on Feed, including the managed and saved feeds. See [Billing](/billing).
      x-mint:
        metadata:
          sidebarTitle: Stream active jobs
      tags: [Feed]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/JobFeedRequest"
            examples:
              occupation_and_company:
                summary: US nursing jobs at mid-size and large employers, no agencies
                value:
                  locations:
                    - country: United States
                  job_subcategories:
                    include: [Nursing]
                  company_sizes:
                    include: ["201-500", "501-1000", "1001-5000", "5001-10000", "10001+"]
                  company_is_agency: false
                  updated_after: "2026-11-01T00:00:00Z"
                  batch_size: 1000
              companies_and_exclusions:
                summary: Last 30 days at two employers, no internships, no Lever postings
                value:
                  companies:
                    include: [fedex.com, UPS]
                  exclude_sources: [lever]
                  exclude_employment_types: [internship]
                  batch_size: 1000
      responses:
        "200":
          description: Batch of jobs with cursor for next page
          headers:
            X-Has-More:
              schema: { type: boolean }
              description: |-
                Mirrors the body's `has_more`, sent as `True` or `False`. Compare it case-insensitively, or read `has_more` from the body.
            X-Credits-Deducted:
              schema: { type: integer }
              description: |-
                Credits charged to the wallet for this response. `0` when included jobs cover every delivered job; absent when the Unlimited plan covers the product.
            X-Credits-Balance:
              schema: { type: integer }
              description: Your wallet balance in credits after the charge. Omitted when nothing was charged.
            X-Billing-Status:
              schema: { type: string, enum: [Failed, Unconfirmed] }
              description: Rare best-effort wallet outcome. Do not automatically replay an Unconfirmed delivery.
            X-Quota-Limit:
              schema: { type: integer }
              description: Your plan's total included jobs across Search, Feed, Export, and Outbound.
            X-Quota-Remaining:
              schema: { type: integer }
              description: Included jobs remaining after this response.
            X-Jobo-Warnings:
              schema: { type: string }
              description: |-
                First page only, when it has `warnings`: their codes, comma-separated (e.g. `companies_unmatched`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobFeedResponse"
        "400":
          description: |
            Invalid request, for example:

            - `batch_size` outside 1–1,000.
            - A malformed cursor (code `invalid_cursor`): discard it and start a new scan.
            - An unknown `company_sizes` or `company_revenues` band.
            - More than 1,000 values in one filter list, counting each `exclude_` list and `companies.include` / `companies.exclude`.
            - An `exclude_` list, `companies`, or an occupation or company filter sent with `stable_scan: false`.
            - Occupation filters in an environment where occupations are not published.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "404":
          $ref: "#/components/responses/NotAvailableInSandbox"
        "409":
          description: |
            Code `feed_cursor_restart_required`: a legacy `stable_scan: false` cursor reached the 1,000,000-result pagination limit. The cursor cannot be retried. Discard it and start a new scan with `{"stable_scan": true, "batch_size": 1000}`, which the problem returns as `replacement_request`.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          description: |
            The feed is temporarily unavailable, or (rarely) its occupation, company and `exclude_` filters can't be applied right now. Retry the same request (same cursor) after the `Retry-After` header (5 seconds).
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds the client should wait before retrying.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  /api/jobs/feed/managed:
    post:
      operationId: getManagedJobFeed
      x-codeSamples:
        - lang: cURL
          source: |
            # First request carries the filters and batch size.
            curl -X POST "https://connect.jobo.world/api/jobs/feed/managed" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"locations": [{"country": "United Kingdom"}], "batch_size": 1000}'

            # Continuation requests carry ONLY the cursor.
            curl -X POST "https://connect.jobo.world/api/jobs/feed/managed" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"cursor": "eyJ2IjoxLCJ..."}'
      summary: Stream the managed feed
      x-mint:
        metadata:
          sidebarTitle: Stream managed jobs
      description: |
        Stream the jobs of your managed job sources: the companies you add in the dashboard under **Managed Services → Job Scraping**. Batches, cursor rules and billing match [`POST /api/jobs/feed`](/api-reference/feed/stream-the-job-feed).

        Differences from the main feed:

        - Pages always run oldest first by `created_at`; there is no `stable_scan` flag.
        - No `sources`, no `exclude_` lists, and no occupation or company filters (`companies`, `job_categories`, `industries`, `company_sizes` and the rest). To filter on those, save a Feed Builder feed that starts from **Managed feed** and stream it with [`POST /api/jobs/feed/{feed_id}`](/api-reference/feed/stream-a-saved-feed).
        - `updated_after` also returns every job of a company you added on or after that time, so an incremental sync picks up a new company's existing jobs.

        Needs a customer API key. A sandbox key (`jbe_test_…`) gets `404` with code `not_available_in_sandbox`; a credential without a customer account, such as a marketplace key, gets `403`.
      tags: [Feed]
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ManagedJobFeedRequest"
      responses:
        "200":
          description: Batch of jobs from managed job sources with cursor for next page
          headers:
            X-Has-More:
              schema: { type: boolean }
              description: |-
                Mirrors the body's `has_more`, sent as `True` or `False`. Compare it case-insensitively, or read `has_more` from the body.
            X-Credits-Deducted:
              schema: { type: integer }
              description: |-
                Credits charged to the wallet for this response. `0` when included jobs cover every delivered job; absent when the Unlimited plan covers the product.
            X-Credits-Balance:
              schema: { type: integer }
              description: Your wallet balance in credits after the charge. Omitted when nothing was charged.
            X-Billing-Status:
              schema: { type: string, enum: [Failed, Unconfirmed] }
              description: Rare best-effort wallet outcome. Do not automatically replay an Unconfirmed delivery.
            X-Quota-Limit:
              schema: { type: integer }
              description: Your plan's total included jobs across Search, Feed, Export, and Outbound.
            X-Quota-Remaining:
              schema: { type: integer }
              description: Included jobs remaining after this response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobFeedResponse"
        "400":
          description: |
            Invalid request: `batch_size` outside 1–1,000, a malformed cursor, or managed job sources that resolve to more than 20,000 companies.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          description: |
            The credential is valid but carries no customer account (for example a marketplace key). The managed feed requires a customer API key. A sandbox key gets `404` with code `not_available_in_sandbox` instead.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "404":
          $ref: "#/components/responses/NotAvailableInSandbox"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          description: The feed store is temporarily unavailable. Retry the same request (same cursor) after the `Retry-After` header (5 seconds).
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds the client should wait before retrying.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  /api/jobs/feed/{feed_id}:
    post:
      operationId: getSavedJobFeed
      x-codeSamples:
        - lang: cURL
          source: |
            # The feed's rules live in the dashboard; the request carries only paging.
            curl -X POST "https://connect.jobo.world/api/jobs/feed/3f2a8c1e-7b0d-4b6a-9a51-0c2f5d9e8b11" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"batch_size": 1000}'

            # Continuation requests carry the previous response's cursor.
            curl -X POST "https://connect.jobo.world/api/jobs/feed/3f2a8c1e-7b0d-4b6a-9a51-0c2f5d9e8b11" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"cursor": "eyJ2IjoxLCJ..."}'
      summary: Stream a saved feed
      x-mint:
        metadata:
          sidebarTitle: Stream a saved feed
      description: |
        Stream a feed you saved in the dashboard under **Developers → Feed Builder**. It starts from **All jobs** or your **Managed feed** and narrows it with company conditions, job conditions and excluded companies; [Saved feeds](/guides/sync-a-database#saved-feeds) lists every condition. Edit the rules in the dashboard: the request carries only `cursor`, `batch_size`, `updated_after` and `description_format`.

        Batches, order (oldest first by `created_at`) and billing match [`POST /api/jobs/feed`](/api-reference/feed/stream-the-job-feed). The cursor fixes `updated_after`, `batch_size` and the "posted within" window; a continuation may lower `batch_size` but not raise it. `updated_after` also returns every job of a company that joined the feed on or after that time.

        Every response carries `feed_revision` (also `X-Feed-Revision`). It changes when the feed's source, conditions or excluded companies change, not on a rename or description edit. A change mid-scan makes the next page return `409 feed_changed`: start again without a cursor. If it differs from your last full scan's, run a full scan instead of an incremental one.

        Needs a customer API key of the account that owns the feed. A sandbox key gets `404` with code `not_available_in_sandbox`.
      tags: [Feed]
      parameters:
        - name: feed_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: The feed's id, shown in the dashboard's Feed Builder.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SavedJobFeedRequest"
      responses:
        "200":
          description: Batch of jobs from the saved feed with cursor for next page
          headers:
            X-Has-More:
              schema: { type: boolean }
              description: |-
                Mirrors the body's `has_more`, sent as `True` or `False`. Compare it case-insensitively, or read `has_more` from the body.
            X-Feed-Revision:
              schema: { type: integer }
              description: Duplicates response body `feed_revision`.
            X-Credits-Deducted:
              schema: { type: integer }
              description: |-
                Credits charged to the wallet for this response. `0` when included jobs cover every delivered job; absent when the Unlimited plan covers the product.
            X-Credits-Balance:
              schema: { type: integer }
              description: Your wallet balance in credits after the charge. Omitted when nothing was charged.
            X-Billing-Status:
              schema: { type: string, enum: [Failed, Unconfirmed] }
              description: Rare best-effort wallet outcome. Do not automatically replay an Unconfirmed delivery.
            X-Quota-Limit:
              schema: { type: integer }
              description: Your plan's total included jobs across Search, Feed, Export, and Outbound.
            X-Quota-Remaining:
              schema: { type: integer }
              description: Included jobs remaining after this response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SavedJobFeedResponse"
        "400":
          description: |
            Invalid request — batch_size out of range, a cursor that is malformed or belongs to another feed (restart without it), or a feed that resolves to more than 20,000 companies.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          description: |
            The credential is valid but is not a customer API key (for example a marketplace key). Saved feeds require a customer API key of the account that owns the feed. A sandbox key gets `404` instead.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "404":
          description: No feed with this id belongs to your account. A sandbox key also gets `404`, with code `not_available_in_sandbox`.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "409":
          description: |
            Code `feed_changed`: the feed's source, conditions or excluded companies changed since the scan started (a rename does not count). The problem carries `restart_required: true` and the current `feed_revision`. Discard the cursor and start a new scan.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          description: |
            The feed is temporarily unavailable: its backend failed, or the read took longer than 90 seconds. Retry the same request (same cursor) after the Retry-After response header (5 seconds).
          headers:
            Retry-After:
              schema: { type: integer }
              description: Seconds the client should wait before retrying.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  /api/jobs/expired:
    get:
      operationId: getExpiredJobIds
      x-codeSamples:
        - lang: cURL
          source: |
            curl -G "https://connect.jobo.world/api/jobs/expired" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              --data-urlencode "batch_size=10000"
        - lang: Python
          source: |
            import datetime as dt
            import os

            from jobo_enterprise import JoboClient

            since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=1)

            with JoboClient(api_key=os.environ["JOBO_API_KEY"]) as client:
                for job_id in client.feed.iter_expired_job_ids(expired_since=since):
                    mark_expired(job_id)
      summary: List expired jobs
      description: |-
        Return the IDs of jobs that stopped appearing on the employer's career site, so you can close them in your own store. Looks back at most 7 days: a sync that stalls longer must re-scan the feed instead ([Expiry](/concepts#expiry)). Free, but counts toward the `JobFeed` [rate limit](/rate-limits).
      x-mint:
        metadata:
          sidebarTitle: Detect expired jobs
      tags: [Feed]
      parameters:
        - name: expired_since
          in: query
          required: false
          schema:
            type: string
            format: date-time
            nullable: true
          description: |
            Return jobs that expired on or after this UTC timestamp (ISO 8601). Defaults to 24 hours ago. A value older than 7 days or in the future returns `400`, so compute it from your last run instead of hard-coding a date.
        - name: cursor
          in: query
          schema:
            type: string
          description: |-
            `next_cursor` from the previous response; omit it on the first request. A cursor that cannot be decoded is ignored and the first page is returned again.
        - name: batch_size
          in: query
          schema:
            type: integer
            default: 1000
            minimum: 1
            maximum: 10000
          description: Number of IDs per page, 1–10,000 (default 1,000).
      responses:
        "200":
          description: Batch of expired job IDs
          headers:
            X-Has-More:
              schema: { type: boolean }
              description: |-
                Mirrors the body's `has_more`, sent as `True` or `False`. Compare it case-insensitively, or read `has_more` from the body.
            Cache-Control:
              schema: { type: string }
              description: |-
                Always `private, max-age=30`: a client-side cache may reuse a response for up to 30 seconds.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ExpiredJobIdsFeedResponse"
        "400":
          description: |
            Invalid query parameter — `expired_since` older than 7 days, in the future, or `batch_size` out of range.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotAvailableInSandbox"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/ServerError"

  /api/jobs/{id}:
    get:
      operationId: getJobById
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://connect.jobo.world/api/jobs/a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
              -H "X-Api-Key: $JOBO_API_KEY"
      summary: Retrieve a job
      x-mint:
        metadata:
          sidebarTitle: Get a job
      tags: [Jobs]
      description: |
        Fetch one job by the `id` that search and feed responses return. The response is the same [job object](/api-reference/jobs/object) (`JobDto`); an unknown `id` returns `404`.

        Free: never billed, but counts toward the `JobSearch` [rate limit](/rate-limits) (`X-RateLimit-*`), like `GET /api/jobs`.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: |
            Stable Jobo job identifier — the `id` field returned on every job by the search and feed endpoints.
        - name: description_format
          in: query
          schema:
            type: string
            enum: [markdown, html]
            default: markdown
          description: |
            Format of the job's `description`. `markdown` (default) returns it as stored. `html` renders it to HTML on the fly: raw HTML in the source is escaped and only `http(s)`, `mailto` and relative URLs are kept.
      responses:
        "200":
          description: The requested job posting
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobDto"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No job exists with the given id
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/SearchUnavailable"

  # ──────────────────────────────────────────────
  # ──────────────────────────────────────────────
  #  Companies
  # ──────────────────────────────────────────────
  /api/companies/{id}:
    get:
      operationId: getCompanyById
      x-codeSamples:
        - lang: cURL
          source: |
            # This endpoint is anonymous - no API key required.
            curl "https://connect.jobo.world/api/companies/b5e6f7a8-1234-5678-9abc-def012345678"
        - lang: Python
          source: |
            import os

            from jobo_enterprise import JoboClient

            with JoboClient(api_key=os.environ["JOBO_API_KEY"]) as client:
                company = client.companies.get("b5e6f7a8-1234-5678-9abc-def012345678")
                print(company.name, company.website, company.industries)
      summary: Retrieve a company
      description: |-
        Fetch one company profile by its Jobo ID, including industry, size and the career-site source Jobo reads it from. Anonymous and free: it needs no API key, is never billed and is not rate limited. A [sandbox key](/sandbox) in `X-Api-Key` returns sandbox companies instead. Returns `404` for an unknown or disabled company.
      x-mint:
        metadata:
          sidebarTitle: Get a company
      tags: [Companies]
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Stable company UUID returned as `company.id` on each job.
      responses:
        "200":
          description: The requested company profile
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompanyDto"
        "404":
          description: Company not found or disabled
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "500":
          $ref: "#/components/responses/ServerError"

  /api/companies/by-domain:
    get:
      operationId: getCompanyByDomain
      x-codeSamples:
        - lang: cURL
          source: |
            # First call: the profile is built while you wait (up to `wait` seconds), or you get a 202 to poll.
            curl -G "https://connect.jobo.world/api/companies/by-domain" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              --data-urlencode "domain=stripe.com" \
              --data-urlencode "wait=25"
      summary: Resolve a domain to a company profile
      description: |-
        Beta, available on activation: email support@jobo.world to switch it on. Until then, calls return `403` with `code: company_enrichment_not_enabled` and cost nothing.

        Build or fetch a company profile from a website domain, in the `CompanyDto` shape. Evidence comes from the company's own site (live and from Common Crawl), Jobo's company data and public datasets; fields it does not support come back null, not guessed. The returned `id` belongs to the domain's profile, not a Jobo company, so it does not resolve at `GET /api/companies/{id}`. `listing_url` is set only when an ATS listing is known for the domain.

        A cached profile returns `200` at once. A new domain that is not done within `wait` seconds returns `202` with a `status_url`; cold runs usually take one to five minutes. Poll `status_url` rather than calling again: each call here counts toward the `DataSets` rate limit, while the poll counts toward `Default` and is free while the run is pending.

        Each delivered profile costs $0.003 (3 credits), cached ones included, charged up front and refunded on any error. `X-Jobo-Profile-Cache` is `hit`, `stale` (served past its cache period) or `miss`. See [Enrich companies](/guides/enrich-companies#enrich-a-company-from-its-domain-beta) for the 180-day cache and how a `202` is billed.
      x-mint:
        metadata:
          sidebarTitle: Resolve a domain
          tag: Beta
      tags: [Companies]
      parameters:
        - name: domain
          in: query
          required: true
          schema:
            type: string
          description: Website domain or URL, e.g. `stripe.com`.
        - name: wait
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            maximum: 60
            default: 25
          description: Seconds to wait for a cold run before answering `202`.
        - name: refresh
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: Ignore the cached profile and stored evidence; re-acquire everything. Fresh runs are limited per domain per day; past the limit the request answers `503` with `Retry-After`.
      responses:
        "200":
          description: The company profile
          headers:
            X-Jobo-Profile-Cache:
              schema:
                type: string
                enum: [hit, stale, miss]
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompanyDto"
        "202":
          description: The profile is being built; poll `status_url` after `retry_after_seconds`
          headers:
            Location:
              description: The `status_url` to poll.
              schema:
                type: string
            Retry-After:
              description: Seconds to wait before polling.
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompanyProfilePendingResponse"
        "400":
          description: Not a usable public website domain
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "403":
          description: Company enrichment by domain is in beta and not yet activated for this account (`code` is `company_enrichment_not_enabled`). Email support@jobo.world to get access
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "404":
          description: The domain is dead, parked, or refused every fetch; cached briefly. A sandbox key gets `404` with code `not_available_in_sandbox`.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/FlatRatePaymentRequired"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          description: Company profiling is temporarily unavailable; nothing was charged. Retry after `Retry-After`
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  /api/companies/by-domain/status:
    get:
      operationId: getCompanyByDomainStatus
      x-codeSamples:
        - lang: cURL
          source: |
            curl -G "https://connect.jobo.world/api/companies/by-domain/status" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              --data-urlencode "domain=stripe.com"
      summary: Poll a domain profile run
      description: |-
        Beta, available on activation, like `GET /api/companies/by-domain`; accounts without access get `403`.

        Poll a run that `GET /api/companies/by-domain` started. This endpoint never starts one.

        - `202`: the run is still going, with progress. Free.
        - `200`: the profile. Charges $0.003 (3 credits) unless the call that started the run already paid.
        - `404`: the domain yielded no profile, or no run is in progress. A prepaid run that found nothing is refunded.
      x-mint:
        metadata:
          sidebarTitle: Poll a domain profile
          tag: Beta
      tags: [Companies]
      parameters:
        - name: domain
          in: query
          required: true
          schema:
            type: string
          description: The domain passed to `GET /api/companies/by-domain`.
      responses:
        "200":
          description: The company profile
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompanyDto"
        "202":
          description: Still building
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CompanyProfilePendingResponse"
        "402":
          description: Insufficient credits to deliver the profile. The problem's `title` is `Insufficient credits`; `top_up_url` opens a wallet top-up that covers it (`upgrade_url` and `recommended_plan` are null, since no plan includes Company Enrichment)
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "403":
          description: Company enrichment by domain is in beta and not yet activated for this account (`code` is `company_enrichment_not_enabled`). Email support@jobo.world to get access
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "404":
          description: No profile and no run in progress for this domain. A sandbox key gets `404` with code `not_available_in_sandbox`.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          description: Company profiling is temporarily unavailable, for example while a data source Jobo relies on is down. A prepaid run that found nothing is refunded. Retry after the `Retry-After` interval.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  /api/companies/{id}/jobs:
    get:
      operationId: listJobsByCompany
      x-codeSamples:
        - lang: cURL
          source: |
            curl -G "https://connect.jobo.world/api/companies/b5e6f7a8-1234-5678-9abc-def012345678/jobs" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              --data-urlencode "page_size=50"
        - lang: Python
          source: |
            import os

            from jobo_enterprise import JoboClient

            with JoboClient(api_key=os.environ["JOBO_API_KEY"]) as client:
                jobs = client.companies.get_jobs(
                    "b5e6f7a8-1234-5678-9abc-def012345678",
                    page_size=50,
                )
                print(f"{jobs.total} open roles")
      summary: List a company's jobs
      description: |-
        Return one company's live jobs, newest first, paged with `page` and `page_size`. Use `posted_after` to fetch only jobs posted since your last sync. An unknown company ID returns `200` with an empty `jobs` array, not `404`.

        Billed as Search: delivered jobs use your included jobs first, then the pay-as-you-go rate; the Unlimited plan does not cover them. An empty page is free. See [Billing](/billing).
      x-mint:
        metadata:
          sidebarTitle: List a company's jobs
      tags: [Companies]
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: Jobo company ID, as returned in `company.id` on each job.
        - name: posted_after
          in: query
          schema:
            type: string
            format: date-time
          description: Only return jobs whose employer posting date is on or after this date
        - name: page
          in: query
          schema:
            type: integer
            default: 1
            minimum: 1
          description: Page number (1-indexed)
        - name: page_size
          in: query
          schema:
            type: integer
            default: 25
            minimum: 1
            maximum: 100
          description: Number of results per page (max 100)
        - name: description_format
          in: query
          schema:
            type: string
            enum: [markdown, html]
            default: markdown
          description: |
            Format of each job's `description`. `markdown` (default) returns it as stored. `html` renders it to HTML on the fly: raw HTML in the source is escaped and only `http(s)`, `mailto` and relative URLs are kept.
      responses:
        "200":
          description: Paginated list of jobs scoped to this company
          headers:
            X-Total-Count:
              schema:
                type: integer
              description: Total number of jobs for this company
            X-Total-Pages:
              schema:
                type: integer
              description: Total number of pages
            X-Page:
              schema:
                type: integer
              description: Current page number
            X-Page-Size:
              schema:
                type: integer
              description: Number of results per page
            X-Credits-Deducted:
              schema: { type: integer }
              description: |-
                Credits charged to the wallet for this response. `0` when included jobs cover every delivered job; absent on an empty page.
            X-Credits-Balance:
              schema: { type: integer }
              description: Your wallet balance in credits after the charge. Omitted when nothing was charged.
            X-Billing-Status:
              schema: { type: string, enum: [Failed, Unconfirmed] }
              description: |-
                Rarely sent. `Unconfirmed` means the jobs were delivered but Jobo could not confirm the wallet charge; do not replay the request automatically.
            X-Quota-Limit:
              schema: { type: integer }
              description: Your plan's total included jobs across Search, Feed, Export, and Outbound.
            X-Quota-Remaining:
              schema: { type: integer }
              description: Included jobs remaining after this response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JobSearchResponse"
        "400":
          description: Invalid pagination parameters
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/ServerError"
        "503":
          $ref: "#/components/responses/SearchUnavailable"

  # ──────────────────────────────────────────────
  #  Auto Apply Applications
  # ──────────────────────────────────────────────
  /api/auto-apply/applications:
    post:
      operationId: createAutoApplyApplication
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST "https://connect.jobo.world/api/auto-apply/applications" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              -H "Content-Type: application/json" \
              -H "Idempotency-Key: $(uuidgen)" \
              --max-time 120 \
              -d '{"job_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}'
      summary: Create an application
      description: |
        Auto Apply is coming soon. Sandbox keys (`jbe_test_…`) work today with no approval. A live key (`jbe_live_…`) gets `403` until Jobo approves [live access](/api-reference/auto-apply/auto-apply#access), requested under **Auto Apply → Getting started → Request live access**.

        Send exactly one of `job_id` or `apply_url`. The request blocks until the first step's fields are discovered, then returns `awaiting_answers` with the fields in `current_step`, or a terminal failure. There is no candidate profile: you answer every field.

        Discovery takes 10 seconds to 3 minutes, but a request holds for at most 90 seconds, so set your HTTP client timeout above 120 seconds. On `202`, repeat the request with the same `Idempotency-Key` (kept for 24 hours), or call `GET /api/auto-apply/applications/{id}?wait_seconds=90`, until the application is answerable or terminal. See [The application loop](/api-reference/auto-apply/flow).
      x-mint:
        metadata:
          sidebarTitle: Create an application
      x-availability: coming-soon
      tags: [Auto Apply]
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: Unique key for this create request. Retained for 24 hours.
          schema:
            type: string
            minLength: 1
            maxLength: 200
          example: application-01J2Q7Y4V9T3
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateAutoApplyApplicationRequest"
            examples:
              job_id:
                summary: Apply to a Jobo job
                value:
                  job_id: 2c1ac0f5-8772-43d2-b5a8-26b44a3fa171
              apply_url:
                summary: Use an apply URL
                value:
                  apply_url: https://boards.greenhouse.io/acme/jobs/4567890
              sandbox:
                summary: Run a scenario on the Jobo sandbox
                value:
                  apply_url: https://sandbox.jobo.world/apply/education-and-work-history
      responses:
        "200":
          description: |-
            The application reached a decision point: awaiting_answers with current_step carrying the discovered fields, or a terminal failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoApplyApplication"
        "202":
          description: |-
            The server wait budget expired while the application was still queued or running. The body is a snapshot; re-attach by retrying with the same Idempotency-Key or via GET with wait_seconds.
          headers:
            Location:
              description: Relative URL of the accepted application.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoApplyApplication"
        "400":
          description: |-
            The request failed validation, for example neither or both of `job_id` and `apply_url` were sent (`invalid_target`), or the `Idempotency-Key` header is missing or longer than 200 characters (`idempotency_key_required`).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |-
            A live key is missing live access. `code` names the first check that failed: `auto_apply_agreement_required` (accept the current Auto Apply Terms and DPA), `auto_apply_not_enabled` (Jobo has not enabled Auto Apply for the account) or `auto_apply_review_required` (the business review is not approved yet). Not transient; do not retry. Sandbox keys skip these checks.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
              example:
                title: auto_apply_not_enabled
                status: 403
                detail: Auto Apply is not enabled for this account.
                code: auto_apply_not_enabled
                api_version: "2026-08-31"
        "404":
          description: job_id is invalid or unknown.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "409":
          description: |-
            - `concurrency_limit_reached`: the account is at its concurrency limit (the body carries `limit` and `active`). Finish or cancel an application instead of retrying.
            - `idempotency_key_reuse`: the key was already used with a different body.
            - `idempotency_key_in_flight`: a request with this key is still being resolved. Retry shortly with the same key.
            - `ambiguous_ats`: the apply URL matches more than one Auto Apply provider.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/AutoApplyCreateConflictProblem"
              example:
                title: concurrency_limit_reached
                status: 409
                detail: You have reached your limit of 2 concurrent applications. Please cancel or submit your existing applications before starting a new one.
                code: concurrency_limit_reached
                limit: 2
                active: 2
                api_version: "2026-08-31"
        "422":
          description: |-
            The job has no apply URL, no Auto Apply provider handles the apply URL (`unsupported_ats`), or a sandbox key targeted something other than a Jobo sandbox form at `https://sandbox.jobo.world/apply/…` (`invalid_target`).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          description: |-
            No verified Auto Apply provider catalog is published, so no apply URL can be routed. Transient - retry with backoff.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
              example:
                title: provider_catalog_unavailable
                status: 503
                detail: The verified Auto Apply provider catalog is unavailable.
                code: provider_catalog_unavailable
                api_version: "2026-08-31"
    get:
      operationId: listAutoApplyApplications
      x-codeSamples:
        - lang: cURL
          source: |
            curl -G "https://connect.jobo.world/api/auto-apply/applications" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              --data-urlencode "limit=20"
      summary: List applications
      description: |
        Auto Apply is coming soon. Listing needs no live access.

        List your account's applications, newest first. A key lists only applications in its own mode: sandbox keys see sandbox applications, live keys see live ones. Pass `next_cursor` back as `cursor` with the same filters; a cursor used with different filters returns `400` `invalid_cursor`.
      x-mint:
        metadata:
          sidebarTitle: List applications
      x-availability: coming-soon
      tags: [Auto Apply]
      parameters:
        - name: status
          in: query
          description: Return only applications in this lifecycle state.
          schema:
            $ref: "#/components/schemas/AutoApplyApplicationStatus"
        - name: created_after
          in: query
          description: Include applications created at or after this UTC timestamp.
          schema:
            type: string
            format: date-time
        - name: created_before
          in: query
          description: Include applications created at or before this UTC timestamp.
          schema:
            type: string
            format: date-time
        - name: cursor
          in: query
          description: The `next_cursor` from the previous page. Omit it on the first request.
          schema:
            type: string
        - name: limit
          in: query
          description: Number of applications to return.
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
      responses:
        "200":
          description: Cursor-paged application summaries.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoApplyApplicationList"
        "400":
          description: Invalid status, date range, or cursor.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "503":
          description: Auto Apply management endpoints are temporarily unavailable.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  /api/auto-apply/applications/{id}:
    get:
      operationId: getAutoApplyApplication
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://connect.jobo.world/api/auto-apply/applications/6f9227f6-b704-4b58-a553-79773a041933" \
              -H "X-Api-Key: $JOBO_API_KEY"
      summary: Get an application
      description: |
        Auto Apply is coming soon. Works with sandbox and live keys and needs no live access.

        Get one application: its status, failure details, discovered fields, accepted answers and correction errors. Without `wait_seconds`, it returns at once.

        With `wait_seconds`, the request long-polls until the application is answerable (`awaiting_answers` with `current_step` populated) or terminal, then returns `200`. If the wait ends first, it returns `202` with a snapshot. Use it to re-attach after a blocking create or answers call drops.
      x-mint:
        metadata:
          sidebarTitle: Get an application
      x-availability: coming-soon
      tags: [Auto Apply]
      parameters:
        - name: id
          in: path
          required: true
          description: Application ID.
          schema:
            type: string
            format: uuid
        - name: wait_seconds
          in: query
          required: false
          description: |-
            Long-poll budget in seconds (1-90); larger values are clamped to 90. Loop the request to wait longer. Omit for an immediate snapshot.
          schema:
            type: integer
            minimum: 1
            maximum: 90
      responses:
        "200":
          description: |-
            Detailed application state. With wait_seconds, returned once the application is answerable or terminal.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoApplyApplication"
        "202":
          description: |-
            Only with wait_seconds - the budget expired before the application became answerable. The body is a point-in-time snapshot.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoApplyApplication"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Application not found or owned by another account.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "503":
          description: Auto Apply management endpoints are temporarily unavailable.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  /api/auto-apply/applications/{id}/answers:
    post:
      operationId: submitAutoApplyAnswers
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST "https://connect.jobo.world/api/auto-apply/applications/6f9227f6-b704-4b58-a553-79773a041933/answers" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              -H "Content-Type: application/json" \
              --max-time 600 \
              -d '{"answers": [{"field_id": "first_name", "value": "Ada"}]}'
      summary: Submit answers
      description: |
        Auto Apply is coming soon. On a live application, answers need the same live access as create.

        Submit the complete answer snapshot for the current step. Answers are validated before anything touches the employer's form: invalid ones return `400` with per-field errors and consume no correction round.

        Jobo then fills the page and advances the form: intermediate pages continue and the final page submits automatically. The request blocks until the next decision point:

        - the next step (`awaiting_answers` with a new `current_step`)
        - a correction round on the same step (`correction_round` incremented, `command_errors` listing what the employer's ATS rejected)
        - the terminal result (`submitted` or `failed`)

        A request holds for at most 90 seconds, then returns `202` with a snapshot, so set your HTTP client timeout above 120 seconds. A repeated POST for an already-accepted round attaches to the in-flight wait and ignores its payload, so retrying after a dropped connection is safe.

        Answer before the step's `answers_expire_at`: about 5 minutes, or 90 seconds for a one-time code. Past it, most applications release the browser and keep waiting for your answers (`answers_expire_at` becomes null, and resuming takes a little longer); verification-code steps and providers that cannot resume on a fresh browser fail with `verification_timeout` or `answers_timeout`. See [Deadlines](/api-reference/auto-apply/flow#4-deadlines).
      x-mint:
        metadata:
          sidebarTitle: Submit answers
      x-availability: coming-soon
      tags: [Auto Apply]
      parameters:
        - name: id
          in: path
          required: true
          description: Application ID.
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AutoApplySubmitAnswersRequest"
            examples:
              answers:
                summary: Answer the current step
                value:
                  answers:
                    - field_id: first_name
                      value: Ada
                    - field_id: email
                      value: ada@example.com
                  correction_round: 0
      responses:
        "200":
          description: |-
            The next decision point: the next step's fields, a correction round on the same step, or the terminal result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoApplyApplication"
        "202":
          description: |-
            The answers were accepted but the server wait budget expired before the next decision point. The body is a snapshot; re-attach with GET wait_seconds.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoApplyApplication"
        "400":
          description: |-
            `validation_failed`: one or more answers failed validation. Nothing was consumed; fix the listed answers and submit again.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/AutoApplyValidationProblem"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |-
            A live key is missing live access. `code` names the first check that failed: `auto_apply_agreement_required` (accept the current Auto Apply Terms and DPA), `auto_apply_not_enabled` (Jobo has not enabled Auto Apply for the account) or `auto_apply_review_required` (the business review is not approved yet). A live application on a Jobo sandbox scenario needs only the agreement. Not transient; do not retry. Sandbox keys skip these checks.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
              example:
                title: auto_apply_agreement_required
                status: 403
                detail: Accept the current Auto Apply Terms and Data Processing Agreement in the dashboard before creating applications or submitting answers.
                code: auto_apply_agreement_required
                api_version: "2026-08-31"
        "404":
          description: Application not found or owned by another account.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "409":
          description: |-
            - `invalid_state`: the application is not awaiting answers.
            - `stale_correction_round`: the optional `correction_round` guard does not match the current round.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /api/auto-apply/applications/{id}/cancel:
    post:
      operationId: cancelAutoApplyApplication
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST "https://connect.jobo.world/api/auto-apply/applications/6f9227f6-b704-4b58-a553-79773a041933/cancel" \
              -H "X-Api-Key: $JOBO_API_KEY"
      summary: Cancel an application
      description: |
        Auto Apply is coming soon. Canceling needs no live access and works with sandbox and live keys.

        Request cancellation of a `queued`, `running`, or `awaiting_answers` application. Repeating the request after cancellation is safe. A `submitted` or `failed` application cannot be canceled (`409` `not_cancelable`).
      x-mint:
        metadata:
          sidebarTitle: Cancel an application
      x-availability: coming-soon
      tags: [Auto Apply]
      parameters:
        - name: id
          in: path
          required: true
          description: Application ID.
          schema:
            type: string
            format: uuid
      responses:
        "202":
          description: Cancellation accepted, or the application was already canceled.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AutoApplyApplicationSummary"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Application not found or owned by another account.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "409":
          description: The application is already submitted or failed and cannot be canceled.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "503":
          description: Auto Apply management endpoints are temporarily unavailable.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  /api/auto-apply/mailboxes:
    post:
      operationId: createAutoApplyMailbox
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST "https://connect.jobo.world/api/auto-apply/mailboxes" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              -H "Content-Type: application/json" \
              -d '{"provider": "imap", "address": "candidate@example.com", "imap_host": "imap.gmail.com", "password": "app-password"}'
      summary: Connect a mailbox
      description: |
        Auto Apply is coming soon. Every mailbox endpoint needs Auto Apply enabled for the account (`403` `auto_apply_not_enabled` otherwise), even with a sandbox key. Connecting needs a live key: a sandbox key gets `404` `not_available_in_sandbox`.

        Optional. Connect an inbox that Jobo reads verification codes from, once per account, then pass its ID to the verification call for any application. Without a mailbox, verification steps work the same: read the code yourself and submit it through the answers call.

        - `imap` (Gmail app passwords, custom domains, most providers): Jobo checks the login during the call. You get status `connected`, or `422` `mailbox_verification_failed` with nothing stored.
        - `outlook` (Outlook and Microsoft 365, which no longer accept basic-auth IMAP): returns status `pending` and an `authorization_url`. Open it, or send it to the mailbox owner, to grant consent; the mailbox then becomes `connected`. No `imap_*` fields apply.

        Credentials are encrypted at rest and never returned. See [Email verification](/api-reference/auto-apply/mailboxes).
      x-mint:
        metadata:
          sidebarTitle: Connect a mailbox
      x-availability: coming-soon
      tags: [Auto Apply]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateMailboxRequest"
            examples:
              imap:
                summary: Gmail with an app password
                value:
                  provider: imap
                  address: candidate@gmail.com
                  imap_host: imap.gmail.com
                  password: abcd efgh ijkl mnop
              outlook:
                summary: Outlook / Microsoft 365 via hosted OAuth
                value:
                  provider: outlook
                  address: candidate@outlook.com
      responses:
        "200":
          description: |-
            The mailbox. Status connected for a verified imap mailbox; status pending with authorization_url for an outlook mailbox awaiting consent.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Mailbox"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Jobo has not enabled Auto Apply for this account (`auto_apply_not_enabled`).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "404":
          $ref: "#/components/responses/NotAvailableInSandbox"
        "409":
          description: A mailbox with this address already exists (mailbox_already_exists).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "422":
          description: |-
            Validation failed, the IMAP host or port is not allowed (`unsafe_imap_host`), the credentials were rejected (`mailbox_verification_failed`), or the account's mailbox limit is reached (`mailbox_limit_reached`).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          description: Outlook connections are not configured on this deployment (mailbox_oauth_unavailable).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
    get:
      operationId: listAutoApplyMailboxes
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://connect.jobo.world/api/auto-apply/mailboxes" \
              -H "X-Api-Key: $JOBO_API_KEY"
      summary: List mailboxes
      description: |
        Auto Apply is coming soon. Needs Auto Apply enabled for the account (`403` `auto_apply_not_enabled` otherwise); works with sandbox and live keys.

        List this account's connected mailboxes. Credentials are never included.
      x-mint:
        metadata:
          sidebarTitle: List mailboxes
      x-availability: coming-soon
      tags: [Auto Apply]
      responses:
        "200":
          description: The account's mailboxes.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MailboxList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AutoApplyNotEnabled"
        "429":
          $ref: "#/components/responses/TooManyRequests"

  /api/auto-apply/mailboxes/{id}:
    get:
      operationId: getAutoApplyMailbox
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://connect.jobo.world/api/auto-apply/mailboxes/8b6c2f1a-1c9e-4b7f-9d2e-5a1b3c4d5e6f" \
              -H "X-Api-Key: $JOBO_API_KEY"
      summary: Get a mailbox
      description: |
        Auto Apply is coming soon. Needs Auto Apply enabled for the account (`403` `auto_apply_not_enabled` otherwise); works with sandbox and live keys.

        Fetch one mailbox. While an `outlook` mailbox is pending consent, the response carries a fresh `authorization_url`.
      x-mint:
        metadata:
          sidebarTitle: Get a mailbox
      x-availability: coming-soon
      tags: [Auto Apply]
      parameters:
        - name: id
          in: path
          required: true
          description: Mailbox ID.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The mailbox.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Mailbox"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AutoApplyNotEnabled"
        "404":
          description: Mailbox not found or owned by another account.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
    delete:
      operationId: deleteAutoApplyMailbox
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X DELETE "https://connect.jobo.world/api/auto-apply/mailboxes/8b6c2f1a-1c9e-4b7f-9d2e-5a1b3c4d5e6f" \
              -H "X-Api-Key: $JOBO_API_KEY"
      summary: Delete a mailbox
      description: |
        Auto Apply is coming soon. Needs Auto Apply enabled for the account (`403` `auto_apply_not_enabled` otherwise) and a live key; a sandbox key gets `404` `not_available_in_sandbox`.

        Disconnect the mailbox and permanently delete its stored credentials. This cannot be undone.
      x-mint:
        metadata:
          sidebarTitle: Delete a mailbox
      x-availability: coming-soon
      tags: [Auto Apply]
      parameters:
        - name: id
          in: path
          required: true
          description: Mailbox ID.
          schema:
            type: string
            format: uuid
      responses:
        "204":
          description: The mailbox and its credentials were deleted.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AutoApplyNotEnabled"
        "404":
          description: Mailbox not found or owned by another account. A sandbox key gets `404` with code `not_available_in_sandbox`.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  /api/auto-apply/mailboxes/{id}/check:
    post:
      operationId: checkAutoApplyMailbox
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST "https://connect.jobo.world/api/auto-apply/mailboxes/8b6c2f1a-1c9e-4b7f-9d2e-5a1b3c4d5e6f/check" \
              -H "X-Api-Key: $JOBO_API_KEY"
      summary: Verify a mailbox
      description: |
        Auto Apply is coming soon. Needs Auto Apply enabled for the account (`403` `auto_apply_not_enabled` otherwise); works with sandbox and live keys.

        Re-check connectivity: Jobo opens the inbox with the stored credentials (redeeming the Microsoft refresh token for `outlook`) and sets status to `connected` or `error`. Use it to re-test a mailbox in status `error` before an application run.
      x-mint:
        metadata:
          sidebarTitle: Verify a mailbox
      x-availability: coming-soon
      tags: [Auto Apply]
      parameters:
        - name: id
          in: path
          required: true
          description: Mailbox ID.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The mailbox with its refreshed status.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Mailbox"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AutoApplyNotEnabled"
        "404":
          description: Mailbox not found or owned by another account.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "409":
          description: The mailbox is still pending consent (mailbox_not_connected).
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  /api/auto-apply/mailboxes/{id}/verification:
    post:
      operationId: retrieveAutoApplyMailboxVerification
      x-codeSamples:
        - lang: cURL
          source: |
            curl -X POST "https://connect.jobo.world/api/auto-apply/mailboxes/8b6c2f1a-1c9e-4b7f-9d2e-5a1b3c4d5e6f/verification" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              -H "Content-Type: application/json" \
              --max-time 120 \
              -d '{"application_id": "6f9227f6-b704-4b58-a553-79773a041933", "wait_seconds": 45}'
      summary: Retrieve verification or submission email evidence
      description: |
        Auto Apply is coming soon. Needs Auto Apply enabled for the account (`403` `auto_apply_not_enabled` otherwise); works with sandbox and live keys.

        Optional: skip it if you read the inbox yourself. Otherwise call it as soon as an application pauses on a `one_time_code` step. The request blocks while Jobo polls the mailbox for that application's email; `type` says what it found:

        - `code`: the one-time code. Submit it through the answers endpoint.
        - `link`: the confirmation link, which Jobo opens server-side (HTTPS, public hosts only), with `link_visited` and `link_http_status`.
        - `confirmation`: a post-submit acknowledgment, returned only when you send `type: confirmation`. It must be time-correlated with the application and match its provider or apply host. No link is visited.

        Status `timeout` means no matching email arrived within `wait_seconds`; it is an expected outcome, not an error. The code step's answer window is 90 seconds, so keep `wait_seconds` at the default 45 and your HTTP client timeout above it.

        A returned code or link always appears verbatim in the email, and the email body is never returned. See [Email verification](/api-reference/auto-apply/mailboxes).
      x-mint:
        metadata:
          sidebarTitle: Retrieve mailbox evidence
      x-availability: coming-soon
      tags: [Auto Apply]
      parameters:
        - name: id
          in: path
          required: true
          description: Mailbox ID.
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MailboxVerificationRequest"
      responses:
        "200":
          description: |-
            The poll finished: status `found` with the code, link or confirmation, or status `timeout`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MailboxVerificationResult"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/AutoApplyNotEnabled"
        "404":
          description: Mailbox or application not found, or owned by another account.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "409":
          description: |-
            The mailbox is not connected (`mailbox_not_connected`) or its credentials were rejected (`mailbox_auth_failed`). Reconnect the mailbox.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "422":
          description: application_id missing or invalid.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "503":
          description: |-
            The mail provider kept failing for the whole poll (mailbox_provider_unavailable) — retry shortly.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  # ──────────────────────────────────────────────
  #  Locations
  # ──────────────────────────────────────────────
  /api/locations/geocode:
    get:
      operationId: geocodeLocation
      x-codeSamples:
        - lang: cURL
          source: |
            curl -G "https://connect.jobo.world/api/locations/geocode" \
              -H "X-Api-Key: $JOBO_API_KEY" \
              --data-urlencode "location=San Francisco, CA"
        - lang: Python
          source: |
            import os

            from jobo_enterprise import JoboClient

            with JoboClient(api_key=os.environ["JOBO_API_KEY"]) as client:
                result = client.locations.geocode("San Francisco, CA")
                if result.succeeded:
                    for loc in result.locations:
                        print(loc.display_name, loc.latitude, loc.longitude)
      summary: Geocode a location
      description: |-
        Resolve a free-text place (a city, region or country) into coordinates and normalized administrative names, for filtering jobs by location.

        A place that cannot be resolved, including on a geocoder outage or timeout, returns `200` with `succeeded: false` and an `error`, so check `succeeded` before reading `locations`. Each lookup costs $0.001 (1 credit), resolved or not; only a `4xx` or `5xx` is refunded. Included jobs and the Unlimited plan don't cover it. See [Resolve locations](/guides/resolve-locations).
      x-mint:
        metadata:
          sidebarTitle: Geocode a location
      tags: [Locations]
      parameters:
        - name: location
          in: query
          required: true
          schema:
            type: string
          description: Location string to geocode (e.g. "San Francisco, CA")
      responses:
        "200":
          description: Geocode result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GeocodeResultItem"
        "400":
          description: Missing or empty `location` query parameter
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/FlatRatePaymentRequired"
        "404":
          $ref: "#/components/responses/NotAvailableInSandbox"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"
        "503":
          description: The request was canceled before geocoding finished, for example because the client disconnected.
          content:
            application/problem+json:
              schema:
                $ref: "#/components/schemas/ProblemDetails"

  # ──────────────────────────────────────────────
  #  Occupations
  # ──────────────────────────────────────────────
  /api/occupations:
    get:
      operationId: listOccupations
      x-codeSamples:
        - lang: cURL
          source: |
            curl "https://connect.jobo.world/api/occupations" \
              -H "X-Api-Key: $JOBO_API_KEY"
      summary: List occupations
      x-mint:
        metadata:
          sidebarTitle: List occupations
      tags: [Occupations]
      description: |
        The taxonomy behind every job's `occupation`: categories, then subcategories, then about 3,000 occupations, in one response with no paging. Filter search with a category's `name` in `job_categories`, a subcategory's `name` in `job_subcategories` and an occupation's `id` in `occupation_ids`.

        Filter on occupation `id`s, never names: ids are stable; names can improve between releases. `version` changes when occupations or the hierarchy change, but display names can change without it, so refetch now and then rather than caching by `version` alone.

        Free: never charges the wallet, but counts toward the `Default` rate limit.

        Occupations come from the ESCO classification (European Commission) and O\*NET-SOC codes from the O\*NET database (USDOL/ETA), both under CC BY 4.0. See [Open data licenses and attribution](/data-sources).
      responses:
        "200":
          description: The occupation taxonomy
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OccupationTaxonomyResponse"
              example:
                version: bc3b8e92359c22b8
                categories:
                  - name: Healthcare
                    subcategories:
                      - name: Nursing
                        occupations:
                          - id: jobo:licensed-practical-nurse
                            name: Licensed Practical Nurse
                            isco_code: "3221"
                            onet_soc_codes: []
                          - id: esco:8d3e8aaa-791b-4c75-a465-f3f827028f50
                            name: Registered Nurse
                            isco_code: "2221"
                            onet_soc_codes: ["29-1141.00"]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotAvailableInSandbox"
        "429":
          $ref: "#/components/responses/TooManyRequests"

# ════════════════════════════════════════════════
#  Components
# ════════════════════════════════════════════════
components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: Your Jobo API key. Create one in the [dashboard](https://enterprise.jobo.world/api-keys?src=docs) under **Developers → API Keys**. Live keys start with `jbe_live_` and sandbox keys with `jbe_test_`.

  responses:
    Unauthorized:
      description: Missing, invalid or expired API key. The body is plain JSON whose `error` is `Missing X-Api-Key header` or `Invalid or expired API key`. See [Errors](/errors#401-unauthorized).
      headers:
        WWW-Authenticate:
          description: Always `ApiKey realm="api.jobo.world"`.
          schema:
            type: string
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiAuthError"
    TooManyRequests:
      description: Rate limit exceeded. Nothing is charged. Wait at least `Retry-After` seconds before you retry. See [Rate limits](/rate-limits#when-you-exceed-a-limit).
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            minimum: 0
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ApiRateLimitError"
    PaymentRequired:
      description: |
        Your remaining included jobs plus your wallet balance cannot cover this request, so it returns no jobs. Jobo checks the worst case (a full page or batch) before the request runs; see [Billing](/billing#request-flow). Open `top_up_url` to top up your wallet, or `upgrade_url` to subscribe to the plan in `recommended_plan`.

        Match on the stable `error` value `"Insufficient credits"`; the `detail` sentence can change. Credits are the wallet's wire unit: 1,000 credits = $1.00. The full body is on [Errors](/errors#402-payment-required).
      headers:
        X-Credits-Required:
          description: Credits the request needs beyond your remaining included jobs.
          schema: { type: integer, format: int64 }
        X-Credits-Balance:
          description: Your current wallet balance in credits.
          schema: { type: integer, format: int64 }
        X-Quota-Remaining:
          description: Included jobs remaining, when a Job Search plan applies.
          schema: { type: integer, format: int64 }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/InsufficientCreditsError"
    FlatRatePaymentRequired:
      description: Your wallet balance cannot cover this flat-rate request, which is charged up front. Nothing was charged. Open `top_up_url` to top up your wallet and continue; no plan includes flat-rate lookups, so `upgrade_url` and `recommended_plan` are null. The body is plain JSON whose `error` value `"Insufficient credits"` is a stable match key; it has no `quota_remaining`.
      headers:
        X-Credits-Required:
          description: The request's price in credits.
          schema: { type: integer, format: int64 }
        X-Credits-Balance:
          description: Your current wallet balance in credits.
          schema: { type: integer, format: int64 }
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/InsufficientCreditsError"
    ServerError:
      description: Unexpected server error. Back off and retry. See [Unhandled errors](/errors#unhandled-errors).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/UnhandledError"
    SearchUnavailable:
      description: The job search backend is temporarily unavailable. Nothing is charged. Retry after the `Retry-After` header (5 seconds).
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            minimum: 0
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
    AutoApplyNotEnabled:
      description: Jobo has not enabled Auto Apply for this account (`auto_apply_not_enabled`). This applies to sandbox keys too. Not transient; do not retry.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
          example:
            title: auto_apply_not_enabled
            status: 403
            detail: This account does not have access to Auto Apply.
            code: auto_apply_not_enabled
            api_version: "2026-08-31"
    NotAvailableInSandbox:
      description: A [sandbox key](/sandbox) (`jbe_test_…`) called an endpoint the sandbox does not serve. The body's `code` is `not_available_in_sandbox`. Use a live key.
      content:
        application/problem+json:
          schema:
            $ref: "#/components/schemas/ProblemDetails"
          example:
            type: https://tools.ietf.org/html/rfc9110#section-15.5.5
            title: Not available in the sandbox
            status: 404
            code: not_available_in_sandbox
            detail: Sandbox keys (jbe_test_…) serve job search, job and company lookups, Auto Apply applications, and reading mailboxes. Use a live key for every other endpoint.
            instance: /api/jobs/feed/managed

  schemas:
    # ── Job DTOs ────────────────────────────────
    JobDto:
      type: object
      required:
        - id
        - title
        - normalized_title
        - occupation
        - company
        - description
        - language
        - summary
        - listing_url
        - apply_url
        - locations
        - compensation
        - employment_type
        - workplace_type
        - experience_level
        - source
        - created_at
        - updated_at
        - date_posted
        - valid_through
        - qualifications
        - responsibilities
        - benefits
        - is_work_auth_required
        - is_h1b_sponsor
        - is_clearance_required
      properties:
        id:
          type: string
          format: uuid
        external_id:
          type: string
          nullable: true
          description: The job's identifier on the source ATS or job board. Not necessarily the employer's requisition number, and not globally unique. Omitted when unavailable.
          example: '000123'
        title:
          type: string
        normalized_title:
          type: string
          nullable: true
        occupation:
          type: object
          allOf:
            - $ref: "#/components/schemas/JobOccupationDto"
          nullable: true
          description: The job's occupation. Null when the job is not classified.
        company:
          $ref: "#/components/schemas/JobCompanyDto"
        description:
          type: string
          description: |
            Full job description. Markdown by default; HTML when the request sets `description_format` to `html`.
        language:
          type: string
          nullable: true
          description: Language the description is written in, as a lowercase ISO 639-1 code. Null when it could not be determined.
          example: en
        summary:
          type: string
          nullable: true
        listing_url:
          type: string
        apply_url:
          type: string
        locations:
          type: array
          items:
            $ref: "#/components/schemas/JobLocationDto"
        compensation:
          type: object
          allOf:
            - $ref: "#/components/schemas/JobCompensationDto"
          nullable: true
          description: |
            Compensation as published by the employer. USD annualization is used for salary filters but is not returned in this object.
        employment_type:
          type: string
          nullable: true
          enum: [Full-time, Part-time, Contract, Internship, Freelance, Temporary]
        workplace_type:
          type: string
          nullable: true
          enum: [Remote, Hybrid, On-site]
        experience_level:
          type: string
          nullable: true
          enum: [Intern, "Entry Level", "Mid Level", Senior, Lead, Executive]
        source:
          type: string
          example: greenhouse
          description: |
            ATS or job-board provider id, as listed on [Sources](/sources). It is an open string, not an enum, so newly supported providers do not break generated clients.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        date_posted:
          type: string
          format: date-time
          nullable: true
        valid_through:
          type: string
          format: date-time
          nullable: true
        qualifications:
          $ref: "#/components/schemas/JobQualificationsDto"
        responsibilities:
          type: array
          items:
            type: string
        benefits:
          type: array
          items:
            type: string
        is_work_auth_required:
          type: boolean
          nullable: true
        is_h1b_sponsor:
          type: boolean
          nullable: true
        is_clearance_required:
          type: boolean
          nullable: true

    JobOccupationDto:
      type: object
      description: |
        What kind of job this is: one occupation and its place in the category › subcategory › occupation taxonomy (see `GET /api/occupations`). Null when the job is not classified. The employer's own title stays in `title` and `normalized_title`, and seniority in `experience_level`.
      required: [id, name, category, subcategory, isco_code, onet_soc_codes]
      properties:
        id:
          type: string
          description: |
            Stable taxonomy id: `esco:<uuid>` for an ESCO occupation, or `jobo:<slug>` for one Jobo added. Filter and store on this.
          example: esco:8d3e8aaa-791b-4c75-a465-f3f827028f50
        name:
          type: string
          description: Display name. May improve over time; the `id` does not change.
          example: Registered Nurse
        category:
          type: string
          description: Top level of the taxonomy; the value `job_categories` filters on.
          example: Healthcare
        subcategory:
          type: string
          description: Second level of the taxonomy; the value `job_subcategories` filters on.
          example: Nursing
        isco_code:
          type: string
          nullable: true
          description: ISCO-08 unit group.
          example: "2221"
        onet_soc_codes:
          type: array
          items:
            type: string
          description: |
            O\*NET-SOC 2019 codes; the first seven characters are the SOC 2018 code. Usually one, several where O\*NET splits the occupation, and empty when O\*NET has no equivalent.
          example: ["29-1141.00"]

    JobCompanyDto:
      type: object
      required: [id, name, website, logo_url, summary, industries, categories, linkedin_url, crunchbase_url, details_url]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        website:
          type: string
          nullable: true
        logo_url:
          type: string
          nullable: true
        summary:
          type: string
          nullable: true
        industries:
          type: array
          items:
            type: string
          description: Industry tags describing what the company does.
        categories:
          type: array
          items:
            type: string
          description: Business-model tags, drawn from `b2b`, `b2c`, `b2b2c`, `b2g`, `saas`, `marketplace`, `e-commerce`, `service-provider`, `hardware`, `agency`, `nonprofit`, `government` and `education`.
        linkedin_url:
          type: string
          nullable: true
        crunchbase_url:
          type: string
          nullable: true
        details_url:
          type: string
          nullable: true
          description: |
            Direct Jobo API URL to the full company profile endpoint (`GET https://connect.jobo.world/api/companies/{id}`). RapidAPI consumers should rebuild `/api/companies/{id}` on their marketplace gateway instead of following this URL with a RapidAPI key.

    CompanyDto:
      type: object
      description: Fully enriched company profile.
      required:
        - id
        - name
        - legal_name
        - summary
        - description
        - website
        - ats_provider
        - careers_url
        - listing_url
        - logo_url
        - linkedin_url
        - linkedin_company_id
        - twitter_url
        - facebook_url
        - instagram_url
        - angellist_url
        - youtube_url
        - github_url
        - g2_url
        - crunchbase_url
        - social_links
        - headquarters_location
        - headquarters_city
        - headquarters_region
        - headquarters_regions
        - country_code
        - continent
        - founding_year
        - company_size
        - revenue
        - is_agency
        - industries
        - primary_industry
        - categories
        - naics_codes
        - operating_status
        - ipo_status
        - company_type
        - stock_symbol
        - stock_exchange
        - is_acquired
        - acquired_by_company
        - parent_company_url
        - funding_stage
        - total_funding
        - funds_total_formatted
        - investors
        - funding_rounds
        - leadership_hires
        - layoffs
        - ratings
        - h1b_annual_job_counts
        - h1b_title_distribution
        - technologies
        - technology_mentions
        - technology_list
        - tech_stack
        - products
        - software_used
        - research_focus_areas
        - acquisitions
        - exits
        - subsidiary_list
        - sub_organizations
        - investor_types
        - page_rank
        - enrichment_state
        - field_coverage
        - evidence_updated_at
        - business_evidence_checked_at
        - technology_observations
        - sponsorship_evidence
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        legal_name:
          type: string
          nullable: true
          description: Registered legal entity name when distinct from the trade name.
        summary:
          type: string
          nullable: true
          description: Short company blurb. AI-generated when available, otherwise the SEO meta-description / first paragraph from the enrichment source.
        description:
          type: string
          nullable: true
        website:
          type: string
          nullable: true
        ats_provider:
          type: string
          nullable: true
          description: Applicant tracking system the company hires through, as a [Sources](/sources) id (e.g. `greenhouse`), when detected.
        careers_url:
          type: string
          nullable: true
          description: The company's own careers page, when found.
        listing_url:
          type: string
          nullable: true
          description: |
            Canonical careers / job-listing page URL on the company's ATS (e.g. its Greenhouse / Lever / Workday board), when available.
        logo_url:
          type: string
          nullable: true
        # ── Socials ──────────────────────────────────
        linkedin_url:
          type: string
          nullable: true
        linkedin_company_id:
          type: string
          nullable: true
        twitter_url:
          type: string
          nullable: true
        facebook_url:
          type: string
          nullable: true
        instagram_url:
          type: string
          nullable: true
        angellist_url:
          type: string
          nullable: true
        youtube_url:
          type: string
          nullable: true
        github_url:
          type: string
          nullable: true
        g2_url:
          type: string
          nullable: true
        crunchbase_url:
          type: string
          nullable: true
        social_links:
          type: object
          additionalProperties:
            type: string
          description: |
            Every social / review profile found for the company, keyed by platform (`linkedin`, `twitter`, `facebook`, `instagram`, `youtube`, `github`, `g2`, `crunchbase`, `angellist`, `tiktok`, `glassdoor`, `xing`, `threads`, `bluesky`, `pinterest`, `vimeo`, `medium`, `kununu`, `trustpilot`, `indeed`, `mastodon`). The flat `*_url` fields carry the same values for the platforms that have one; this map is the complete set.
        # ── Location ─────────────────────────────────
        headquarters_location:
          type: string
          nullable: true
        headquarters_city:
          type: string
          nullable: true
          description: Headquarters city when known. Filled by Company Enrichment (`GET /api/companies/by-domain`); always null on `GET /api/companies/{id}`, which carries the address in `headquarters_location`.
        headquarters_region:
          type: string
          nullable: true
          description: Region/state when known.
        headquarters_regions:
          type: array
          items:
            type: string
          description: Additional region tags when more than one applies.
        country_code:
          type: string
          nullable: true
          description: ISO 3166-1 alpha-2 country code.
        continent:
          type: string
          nullable: true
        # ── Basic facts ──────────────────────────────
        founding_year:
          type: string
          nullable: true
        company_size:
          type: string
          nullable: true
          description: 'Headcount band (e.g. "1-10", "51-200").'
        revenue:
          type: string
          nullable: true
          description: 'Annual revenue band (e.g. "under-1m", "100m-200m", "over-1b").'
        is_agency:
          type: boolean
        industries:
          type: array
          items:
            type: string
          description: 'Vertical industry labels (e.g. "HR & Staffing").'
        primary_industry:
          type: string
          nullable: true
          description: 'Single high-level industry label (e.g. "Finance").'
        categories:
          type: array
          items:
            type: string
          description: Business-model tags, drawn from `b2b`, `b2c`, `b2b2c`, `b2g`, `saas`, `marketplace`, `e-commerce`, `service-provider`, `hardware`, `agency`, `nonprofit`, `government` and `education`.
        naics_codes:
          type: array
          items:
            type: string
        # ── Status ───────────────────────────────────
        operating_status:
          type: string
          nullable: true
          description: '"active" | "closed".'
        ipo_status:
          type: string
          nullable: true
          description: '"private" | "ipo" | "delisted".'
        company_type:
          type: string
          nullable: true
          description: '"for_profit" | "non_profit".'
        stock_symbol:
          type: string
          nullable: true
        stock_exchange:
          type: string
          nullable: true
        is_acquired:
          type: boolean
        acquired_by_company:
          type: string
          nullable: true
        parent_company_url:
          type: string
          nullable: true
          description: URL of the parent organization when this company is a subsidiary.
        # ── Funding ──────────────────────────────────
        funding_stage:
          type: string
          nullable: true
        total_funding:
          type: string
          nullable: true
        funds_total_formatted:
          type: string
          nullable: true
          description: Total funds raised when the company itself is a VC firm.
        investors:
          type: array
          items:
            type: string
        funding_rounds:
          type: array
          items:
            $ref: "#/components/schemas/CompanyFundingRoundDto"
        # ── People & culture ─────────────────────────
        leadership_hires:
          type: array
          items:
            $ref: "#/components/schemas/CompanyKeyEventDto"
        layoffs:
          type: array
          items:
            $ref: "#/components/schemas/CompanyKeyEventDto"
        ratings:
          type: array
          items:
            $ref: "#/components/schemas/CompanyRatingDto"
        h1b_annual_job_counts:
          type: array
          items:
            $ref: "#/components/schemas/CompanyH1bJobCountDto"
        h1b_title_distribution:
          type: array
          items:
            $ref: "#/components/schemas/CompanyH1bTitleDistributionDto"
        # ── Products & technology ────────────────────
        technologies:
          type: array
          items:
            type: string
          description: |
            Technologies detected on the company's website, as stable catalog slugs (`google-tag-manager`, `shopify`, `hubspot`, …). Deterministic and normalized; this is the field to filter on.
        technology_mentions:
          type: array
          items:
            type: string
          description: Technologies named in prose that did not map to a catalog slug. Display only.
        technology_list:
          type: array
          items:
            type: string
          description: Flat list of technologies the company is reported to use (catalog names plus mentions).
        tech_stack:
          type: array
          items:
            $ref: "#/components/schemas/CompanyTechnologyDto"
          description: Structured per-technology records.
        products:
          type: array
          items:
            $ref: "#/components/schemas/CompanyProductDto"
        software_used:
          type: array
          items:
            $ref: "#/components/schemas/CompanyProductDto"
        research_focus_areas:
          type: array
          items:
            type: string
        # ── Relationships ────────────────────────────
        acquisitions:
          type: array
          items:
            $ref: "#/components/schemas/CompanyAcquisitionDto"
        exits:
          type: array
          items:
            $ref: "#/components/schemas/CompanyExitDto"
        subsidiary_list:
          type: array
          items:
            type: string
          description: Flat list of subsidiary company names.
        sub_organizations:
          type: array
          items:
            $ref: "#/components/schemas/CompanySubOrganizationDto"
        # ── Investor profile ─────────────────────────
        investor_types:
          type: array
          items:
            type: string
          description: Investor classification (only populated when the company is itself an investor).
        page_rank:
          type: number
          format: double
          nullable: true
          description: Domain authority score (useful for ranking).
        # ── Enrichment evidence ──────────────────────
        enrichment_state:
          type: string
          nullable: true
          enum: [complete, partial, null]
          description: How well the evidence supports this profile. `complete` when the profile passed verification with high confidence; `partial` when identity confidence was low or the company's site could not be read, so expect fewer filled fields. Null on `GET /api/companies/{id}`.
        field_coverage:
          type: object
          additionalProperties:
            type: string
            enum: [supported, unknown, stale]
          description: |
            Whether evidence backs each profile field, keyed by field name (`founding_year`, `headquarters_city`, …): `supported` when a verified source backs the value, `unknown` when none does. Four keys name the underlying fact instead of a response field: `employee_count`, `annual_revenue_usd`, `total_funding_usd` and `headquarters_country`.

            `sponsorship_evidence` can also be `stale`: the latest check of the official registers and job listings failed, so the records shown come from an earlier check. Empty (`{}`) on `GET /api/companies/{id}`.
          example:
            founding_year: supported
            headquarters_city: supported
            annual_revenue_usd: unknown
            sponsorship_evidence: supported
        evidence_updated_at:
          type: string
          format: date-time
          nullable: true
          description: When the newest evidence behind this profile was fetched. Null on `GET /api/companies/{id}`.
        business_evidence_checked_at:
          type: string
          format: date-time
          nullable: true
          description: When `sponsorship_evidence` and the job-listing entries of `technology_observations` were last refreshed from the official registers and the company's job listings on Jobo. Null on `GET /api/companies/{id}`.
        technology_observations:
          type: array
          items:
            $ref: "#/components/schemas/CompanyTechnologyObservation"
          description: The evidence behind `technologies`, one entry per technology and source. Entries with `usage_context` `explicit_use`, `website_detection` or `dns_detection` put the technology in `technologies`; `hiring_requirement` entries (a job ad asks for the skill) do not. Empty on `GET /api/companies/{id}`.
        sponsorship_evidence:
          type: array
          items:
            $ref: "#/components/schemas/CompanySponsorshipEvidence"
          description: Visa sponsorship evidence. Official register records (UK sponsor licences, US Department of Labor and USCIS filings, Canadian LMIAs, Irish employment permits, Dutch recognised sponsors), plus sponsorship statements on the company's own site and in its job ads. Empty on `GET /api/companies/{id}`.

    CompanyTechnologyObservation:
      type: object
      description: One piece of evidence that a company uses a technology.
      required: [technology_id, name, categories, usage_context, quote, source_url, observed_at, confidence, source_kind]
      properties:
        technology_id:
          type: string
          description: Catalog slug, as in `technologies`.
          example: hubspot
        name:
          type: string
          example: HubSpot
        categories:
          type: array
          items:
            type: string
        usage_context:
          type: string
          description: |
            How the technology was observed:

            - `explicit_use`: the company's own pages or job ads say it uses it.
            - `hiring_requirement`: a job ad asks for experience with it.
            - `website_detection`: a fingerprint in the company's markup, such as a script host.
            - `dns_detection`: a record in its DNS zone, such as an MX host or SPF include.
          example: website_detection
        quote:
          type: string
          description: The sentence, or the matched markup or DNS record.
        source_url:
          type: string
          description: Page or job listing the observation comes from.
        observed_at:
          type: string
          format: date-time
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
        source_kind:
          type: string
          nullable: true
          description: '`job_listing` when the observation comes from one of the company''s job listings on Jobo; null otherwise.'

    CompanySponsorshipEvidence:
      type: object
      description: One record of visa sponsorship evidence. Counts are summed over the record's `period`.
      required: [country, programme, legal_employer, evidence_kind, source_url, observed_at, period, registration_id, match_method, quote, filing_count, requested_workers, rating, source, approvals, denials, approved_positions, occupations]
      properties:
        country:
          type: string
          description: ISO 3166-1 alpha-2 code of the register's country. `unknown` for `company_statement` and `job_offer`.
          example: GB
        programme:
          type: string
          description: Visa programme or route as the source names it, such as `H-1B`, `PERM` or `H-2A`; `unspecified` for a statement that names none.
          example: H-1B
        legal_employer:
          type: string
          description: Employer name as the source records it.
        evidence_kind:
          type: string
          description: |
            What the record is. From an official register: `licence`, `recognised_sponsor`, `lca_filing`, `perm_filing`, `h2a_filing`, `h2b_filing`, `uscis_petition_decisions`, `lmia_positive` or `employment_permits_issued`. From the company: `company_statement` (its own site) or `job_offer` (one of its job ads).
          example: lca_filing
        source_url:
          type: string
          description: The register dataset, page or job listing the record comes from.
        observed_at:
          type: string
          format: date-time
          description: When the source was retrieved.
        period:
          type: string
          nullable: true
          description: Reporting period of a register record, such as a fiscal year.
        registration_id:
          type: string
          nullable: true
          description: The employer's identifier in the register, when it has one.
        match_method:
          type: string
          nullable: true
          description: How a register record was matched to this company, from strongest to weakest `registration_id`, `verified_domain`, `legal_name_location` or `legal_name`. Null for `company_statement` and `job_offer`.
        quote:
          type: string
          nullable: true
          description: The sponsorship sentence, for `company_statement` and `job_offer`.
        filing_count:
          type: integer
          nullable: true
          description: Number of filings, for US Department of Labor records.
        requested_workers:
          type: integer
          nullable: true
          description: Workers requested across those filings, for US Department of Labor records.
        rating:
          type: string
          nullable: true
          description: The licence rating, where the register publishes one.
        source:
          type: string
          nullable: true
          description: The official register, such as `uk`, `nl`, `us` (Department of Labor), `us-uscis`, `ca` or `ie`. Null for `company_statement` and `job_offer`.
        approvals:
          type: integer
          nullable: true
          description: Approved petitions, positive LMIAs or permits issued, where the register publishes them.
        denials:
          type: integer
          nullable: true
          description: Denied petitions, where the register publishes them.
        approved_positions:
          type: integer
          nullable: true
          description: Positions approved by positive LMIAs (Canada).
        occupations:
          type: string
          nullable: true
          description: The most frequent occupations in the record (SOC or NOC titles), separated by `; `.

    CompanyFundingRoundDto:
      type: object
      required: [investment_type, announced_on, raised_amount, post_money_valuation, investor_count, lead_investor]
      properties:
        investment_type:
          type: string
          nullable: true
        announced_on:
          type: string
          nullable: true
        raised_amount:
          type: string
          nullable: true
        post_money_valuation:
          type: string
          nullable: true
        investor_count:
          type: integer
        lead_investor:
          type: string
          nullable: true

    CompanyRatingDto:
      type: object
      required: [source, rating, url, review_count]
      properties:
        source:
          type: string
          nullable: true
        rating:
          type: string
          nullable: true
        url:
          type: string
          nullable: true
        review_count:
          type: integer
          nullable: true

    CompanyH1bJobCountDto:
      type: object
      required: [year, count]
      properties:
        year:
          type: string
          nullable: true
        count:
          type: integer

    CompanyH1bTitleDistributionDto:
      type: object
      required: [title, count]
      properties:
        title:
          type: string
          nullable: true
        count:
          type: integer

    CompanyTechnologyDto:
      type: object
      required: [name, categories]
      description: A technology entry in the company's tech stack.
      properties:
        name:
          type: string
          nullable: true
        categories:
          type: array
          items:
            type: string

    CompanyProductDto:
      type: object
      required: [name, description]
      description: A product or piece of software the company offers / uses.
      properties:
        name:
          type: string
          nullable: true
        description:
          type: string
          nullable: true

    CompanyAcquisitionDto:
      type: object
      required: [acquiree_name, title]
      properties:
        acquiree_name:
          type: string
          nullable: true
        title:
          type: string
          nullable: true

    CompanyExitDto:
      type: object
      required: [name, description]
      properties:
        name:
          type: string
          nullable: true
        description:
          type: string
          nullable: true

    CompanySubOrganizationDto:
      type: object
      required: [name, ownership_type, title]
      properties:
        name:
          type: string
          nullable: true
        ownership_type:
          type: string
          nullable: true
        title:
          type: string
          nullable: true

    CompanyKeyEventDto:
      type: object
      required: [date]
      description: A dated event in the company's history (layoff, leadership hire, etc.).
      properties:
        date:
          type: string
          nullable: true


    CompanyProfilePendingResponse:
      type: object
      description: Returned with `202` while a domain profile is being built.
      properties:
        status:
          type: string
          enum: [pending]
        stage:
          type: string
          description: Where the run is — `queued`, `resolving`, `acquiring`, `extracting`.
        sources_done:
          type: integer
        sources_total:
          type: integer
        retry_after_seconds:
          type: integer
        status_url:
          type: string
          description: Relative URL to poll; returns this shape while pending and the profile once ready.
      required: [status, stage, retry_after_seconds, status_url]
    ProblemDetails:
      type: object
      description: RFC 7807 problem details, returned by endpoint errors as `application/problem+json`. See [Errors](/errors) for every envelope and stable code.
      properties:
        type:
          type: string
          nullable: true
        title:
          type: string
          nullable: true
        status:
          type: integer
          nullable: true
        detail:
          type: string
          nullable: true
        instance:
          type: string
          nullable: true
        code:
          type: string
          nullable: true
          description: Stable machine-readable error code, such as `invalid_request`, `invalid_cursor`, `feed_changed`, `not_available_in_sandbox` or an Auto Apply code. Branch on this rather than on `title` or `detail`. See [stable error codes](/errors#stable-error-codes).
        docs_url:
          type: string
          format: uri
          description: Documentation page for the error. Sent with `code` on `400`, `409`, `413`, `415` and `422` responses to requests made with a customer API key.
          example: https://jobo.world/docs/guides/sync-a-database
        api_version:
          $ref: "#/components/schemas/AutoApplyApiVersion"

    ApiAuthError:
      type: object
      description: Body of a gateway `401`. Plain JSON, not `problem+json`. Match on the status; `error` is a sentence that may be reworded.
      required: [error]
      properties:
        error:
          type: string
          example: Invalid or expired API key
        api_version:
          $ref: "#/components/schemas/AutoApplyApiVersion"

    ApiRateLimitError:
      type: object
      description: |
        Body of a gateway `429`. Plain JSON, not `problem+json`. When a rate-limit window is exhausted, `code` is `rate_limit_exceeded` and `group` names the exhausted [rate-limit group](/rate-limits); the Auto Apply groups add `api_version`. The `429` for [repeated authentication failures](/rate-limits#repeated-authentication-failures) has neither `code` nor `group`. `retry_after_seconds` matches the `Retry-After` header.
      required: [error, detail, retry_after_seconds]
      properties:
        code:
          type: string
          enum: [rate_limit_exceeded]
        error:
          type: string
          example: Rate limit exceeded
        detail:
          type: string
          example: Per-minute JobSearch request limit of 180 exceeded. Try again shortly.
        group:
          type: string
          example: JobSearch
        retry_after_seconds:
          type: integer
          minimum: 0
          nullable: true
        api_version:
          $ref: "#/components/schemas/AutoApplyApiVersion"
        upgrade_url:
          type: string
          format: uri
          nullable: true
          description: Sent only without a subscription, outside the Auto Apply groups. Opens checkout for `recommended_plan`, which raises your limits.
          example: https://enterprise.jobo.world/credits?plan=job_search&tier=1&src=api_429
        top_up_url:
          type: string
          format: uri
          nullable: true
          description: Always null on a `429`, because a wallet top-up does not raise rate limits. Sent alongside `upgrade_url`.
        recommended_plan:
          type: object
          allOf:
            - $ref: "#/components/schemas/RecommendedPlan"
          nullable: true
          description: Sent alongside `upgrade_url`. The plan it opens.

    InsufficientCreditsError:
      type: object
      description: Body of a `402`. Plain JSON, not `problem+json`. The `error` value `Insufficient credits` is a stable match key; the `detail` sentence may be reworded at any time.
      required: [error, detail, group, credits_required, credits_balance, cost_per_request_usd]
      properties:
        error:
          type: string
          enum: [Insufficient credits]
        detail:
          type: string
          example: This request needs 75 credits ($0.075). Your wallet balance is 12 credits ($0.012). Top up your wallet to continue.
        group:
          type: string
          description: The [rate-limit group](/rate-limits) the request is priced under.
          example: JobSearch
        credits_required:
          type: integer
          format: int64
          description: Credits the request needs beyond your remaining included jobs.
        credits_balance:
          type: integer
          format: int64
          description: Your wallet balance in credits.
        quota_remaining:
          type: integer
          format: int64
          nullable: true
          description: Included jobs remaining. Sent on per-job endpoints (search, feed, company jobs); null when no Job Search plan applies.
        cost_per_request_usd:
          type: number
          format: double
          description: '`credits_required` in US dollars.'
          example: 0.075
        upgrade_url:
          type: string
          format: uri
          nullable: true
          description: Opens checkout for `recommended_plan` on the credits page. Null when a top-up is the way to continue, as for geocoding and Company Enrichment.
          example: https://enterprise.jobo.world/credits?plan=job_search&tier=2&src=api_402
        top_up_url:
          type: string
          format: uri
          nullable: true
          description: Opens a wallet top-up on the credits page, preset to cover the shortfall.
          example: https://enterprise.jobo.world/credits?topup=10&src=api_402
        recommended_plan:
          type: object
          allOf:
            - $ref: "#/components/schemas/RecommendedPlan"
          nullable: true
          description: The plan that would include this usage, priced for your account. Null whenever `upgrade_url` is null.

    RecommendedPlan:
      type: object
      description: A plan offered on a `402` or `429`. Open `upgrade_url` from the same body to subscribe.
      required: [package_type, tier, name, monthly_price_usd, included_jobs_per_month]
      properties:
        package_type:
          type: string
          enum: [job_search, jobs_feed]
          description: '`job_search` for a Job Search plan; `jobs_feed` for the Unlimited plan.'
        tier:
          type: integer
          minimum: 0
          maximum: 3
          nullable: true
          description: 'Job Search tier: 0 Lite, 1 Starter, 2 Growth, 3 Scale. Null for Unlimited.'
          example: 2
        name:
          type: string
          example: Job Search — Growth
        monthly_price_usd:
          type: number
          format: double
          description: Monthly price in US dollars for your account.
          example: 199
        included_jobs_per_month:
          type: integer
          format: int64
          nullable: true
          description: Jobs included each month, shared across Search, Feed, Export, and Outbound. Null for Unlimited, which has no per-job charge on Feed, Export, and Outbound.
          example: 500000

    UnhandledError:
      type: object
      description: Body of an unexpected `500`, or of a `503` after a transient database conflict. Plain JSON, not `problem+json`.
      required: [message, detail]
      properties:
        message:
          type: string
          example: An error occurred
        detail:
          type: string
          nullable: true
          description: Null in production. Never depend on it.

    JobLocationDto:
      type: object
      required: [location, city, region, country, latitude, longitude]
      properties:
        location:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        region:
          type: string
          nullable: true
        country:
          type: string
          nullable: true
        latitude:
          type: number
          format: double
          nullable: true
        longitude:
          type: number
          format: double
          nullable: true

    JobCompensationDto:
      type: object
      required: [min, max, currency, period]
      properties:
        min:
          type: number
          format: decimal
          nullable: true
        max:
          type: number
          format: decimal
          nullable: true
        currency:
          type: string
          nullable: true
        period:
          type: string
          nullable: true
          enum: [hourly, daily, weekly, monthly, yearly, per-diem]

    JobQualificationsDto:
      type: object
      required: [must_have, preferred]
      properties:
        must_have:
          $ref: "#/components/schemas/QualificationBucketDto"
        preferred:
          $ref: "#/components/schemas/QualificationBucketDto"

    QualificationBucketDto:
      type: object
      required: [education, certifications, skills]
      properties:
        education:
          type: array
          items:
            type: string
        certifications:
          type: array
          items:
            type: string
        skills:
          type: array
          items:
            $ref: "#/components/schemas/QualificationSkillDto"

    QualificationSkillDto:
      type: object
      required: [name, type]
      properties:
        name:
          type: string
        type:
          type: string
          default: hard

    # ── Search & Feed Responses ─────────────────
    JobSearchResponse:
      type: object
      required: [jobs, total, page, page_size, total_pages, facets]
      properties:
        jobs:
          type: array
          items:
            $ref: "#/components/schemas/JobDto"
        total:
          type: integer
          format: int64
        page:
          type: integer
        page_size:
          type: integer
        total_pages:
          type: integer
        facets:
          type: object
          additionalProperties:
            type: array
            items:
              $ref: "#/components/schemas/JobFacetDto"
        warnings:
          type: array
          nullable: true
          description: |-
            Non-fatal problems with the request. Omitted when there is nothing to report. The same codes are sent, comma-separated, in the `X-Jobo-Warnings` response header, so a client can branch without parsing the body.
          items:
            $ref: "#/components/schemas/SearchWarning"
        filters:
          type: object
          nullable: true
          description: |-
            How the server interpreted the filters that needed resolving. Present only when a `companies` filter was supplied.
          properties:
            companies:
              $ref: "#/components/schemas/CompanyFilterDiagnostics"

    JobFacetDto:
      type: object
      required: [key, count]
      properties:
        key:
          type: string
          description: |
            The bucket's value, which can be passed straight back as a filter value. On `job_categories` and `job_subcategories` it is the lowercased name (`healthcare`, `nursing`); on `occupations` it is the occupation id.
        count:
          type: integer
          format: int64
          description: Number of matching jobs in the bucket.
        name:
          type: string
          description: |
            Occupation facets only: the display name as the taxonomy spells it (`Healthcare` for the key `healthcare`), or the occupation's name. Omitted on other facets and on buckets the current taxonomy no longer has.
          example: Nursing
        parent:
          type: string
          description: |
            `job_subcategories` and `occupations` buckets only: the key one level up (a subcategory's category, an occupation's subcategory), so the facets nest category › subcategory › occupation. Omitted on `job_categories` and other facets.
          example: healthcare

    JobSearchBodyRequest:
      type: object
      properties:
        queries:
          type: array
          items:
            type: string
          nullable: true
          description: |-
            Free-text queries, OR'd together. Each entry matches broadly with typo tolerance; `search_description` chooses the fields. Double-quote an entry (`"\"Quantitative Developer\""` in JSON) for an exact, contiguous phrase match on the job title only. Prefix an entry with `-` to exclude it. Up to 10 positive entries; more returns `400`, and `-` entries don't count. See [How text queries match](/guides/search-recipes#how-text-queries-match).
        search_description:
          type: boolean
          default: true
          description: |-
            Which fields `queries` match. `true` (default): the title, curated alternative titles, company, popular skills and summary, so `python` finds a Software Engineer job that lists Python as a skill. `false`: the job's own title and curated same-role alternative titles only, so `Android Developer` also finds `Android Engineer` but not a job that only lists Android as a skill. Use `false` when you pass exact job titles.

            Double-quoted entries match the literal job title only, without alternative titles, in either mode. See [How text queries match](/guides/search-recipes#how-text-queries-match).
        locations:
          type: array
          items:
            type: string
          nullable: true
          description: Location strings, resolved by the same geocoder as `GET /api/locations/geocode`. See [Resolve locations](/guides/resolve-locations).
        sources:
          type: array
          items:
            type: string
          nullable: true
          description: Restrict to these ATS provider ids (e.g. `greenhouse`, `lever`, `workday`). See [Sources](/sources) for every id.
        skills:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: Include/exclude skills filter
        companies:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude companies. Each entry is matched by its form:

            - A GUID (any format or casing) matches the Jobo company id, as in `company.id` and `filters.companies.matched`.
            - A domain or URL (`fedex.com`, `https://www.fedex.com/careers`) matches the website domain, ignoring scheme, path, port and a leading `www.`. Other subdomains count, so use the main domain. A value that parses as a domain is never retried as a name.
            - Anything else matches the whole company name, ignoring case, accents, punctuation and trailing legal suffixes (Inc, Corp, Ltd, plc, GmbH, …). Not a partial match: `Glenmark` does not find `Glenmark Pharmaceuticals`.

            Unmatched entries never fail the request. Unmatched `include` entries are listed in `filters.companies.unmatched` with a `companies_unmatched` warning; if every one misses, you get `200` with zero jobs, not `400`. An unmatched `exclude` entry excludes nothing (`companies_exclude_unmatched`). Companies on none of the requested `sources` (a Workday company with `sources: [greenhouse]`) return nothing and are reported as `companies_outside_sources`.

            See [Search recipes](/guides/search-recipes#target-or-exclude-specific-employers).
        industries:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude company-industry filter. Matched on the whole industry name, case-insensitively — not a partial match.
        company_categories:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude filter on the hiring company's business model (`company.categories`), such as `b2b`, `b2c`, `b2b2c`, `saas`, `marketplace` or `service-provider`. Exact, case-insensitive match.
        job_categories:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude filter on the job's occupation category (`occupation.category`, e.g. `Healthcare`). Exact, case-insensitive match. `GET /api/occupations` lists every value. Jobs without an `occupation` never match an include list.
        job_subcategories:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude filter on the job's occupation subcategory (`occupation.subcategory`, e.g. `Nursing`). Exact, case-insensitive match. `GET /api/occupations` lists every value.
        occupation_ids:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude filter on occupation id (`occupation.id`, e.g. `esco:8d3e8aaa-791b-4c75-a465-f3f827028f50`). Use ids, not names: names can improve between taxonomy releases, ids don't. `GET /api/occupations` lists every id.
        work_models:
          type: array
          items:
            type: string
            enum: [remote, hybrid, onsite]
          nullable: true
          description: "Filter by work model: remote, hybrid, onsite"
        employment_types:
          type: array
          items:
            type: string
            enum: [full-time, part-time, contract, internship, freelance, temporary]
          nullable: true
          description: "Filter by employment type: full-time, part-time, contract, internship, freelance, temporary"
        experience_levels:
          type: array
          items:
            type: string
            enum: [intern, entry, mid, senior, lead, executive]
          nullable: true
          description: |-
            Exact-match experience-level filter. Canonical values are `intern`, `entry`, `mid`, `senior`, `lead`, and `executive`.
        languages:
          type: array
          items:
            type: string
          nullable: true
          description: |-
            Filter by description language, as ISO 639-1 codes (`en`, `de`, `fr`). Language names and locales (`English`, `en-US`) are mapped to their code. null = all jobs.
        salary_usd:
          type: object
          allOf:
            - $ref: "#/components/schemas/RangeFilterInt"
          nullable: true
          description: |
            Annualized USD range-overlap filter. A job matches when its disclosed salary range intersects the requested range. Jobs without disclosed compensation are excluded.
        posted_after:
          type: string
          format: date-time
          nullable: true
          description: Only return jobs whose employer posting date is on or after this date
        posted_before:
          type: string
          format: date-time
          nullable: true
          description: Only return jobs whose employer posting date is on or before this date
        discovered_after:
          type: string
          format: date-time
          nullable: true
          description: Only return jobs first indexed on or after this UTC timestamp
        discovered_before:
          type: string
          format: date-time
          nullable: true
          description: Only return jobs first indexed on or before this UTC timestamp
        sort:
          type: string
          enum: [relevance, discovered_asc, discovered_desc]
          default: relevance
          description: |-
            Result order. `relevance` (default) ranks by match quality, or by most recently updated when there are no `queries`. Jobs that match every word of a query rank first, so take results from the top. Relevance order shifts as jobs update, so deep paging can skip or repeat a job. `discovered_asc` and `discovered_desc` order by `created_at`, which never changes: pair them with `discovered_after` / `discovered_before` for [stable pages](/pagination#stable-pages-over-a-fixed-window).
        page:
          type: integer
          default: 1
          minimum: 1
        page_size:
          type: integer
          default: 25
          minimum: 1
          maximum: 100
        include_facets:
          type: array
          items:
            type: string
            enum: [work_model, experience_level, employment_type, sources, industries, skills, countries, company_categories, job_categories, job_subcategories, occupations]
          nullable: true
          description: |-
            Facets to return. Omit (or `null`) for the defaults: `work_model`, `experience_level`, `employment_type` and `sources`. Pass `[]` for none. Every other facet is computed only when requested. Each facet returns at most 8 buckets, and unknown names are ignored.

            A bucket `key` goes straight back in as a filter value: `countries` keys (lowercased names such as `united states`) as `locations`, `occupations` keys as `occupation_ids`. Occupation buckets also carry `name` and `parent`. See [Facets](/guides/search-recipes#facets).
        include_fields:
          type: array
          items:
            type: string
            enum: [description, summary, qualifications, responsibilities, benefits]
          nullable: true
          description: |-
            Non-core fields to return. Omit (or `null`) for the full job. Pass a subset to keep only those, or `[]` for core fields only. Unknown names are ignored. Excluded fields are still present but empty: `description` is `""`, `summary` is `null`, `responsibilities` and `benefits` are `[]`, and `qualifications` has empty buckets. See [Shrinking the response](/guides/search-recipes#shrinking-the-response).
        description_format:
          type: string
          enum: [markdown, html]
          default: markdown
          description: |
            Format of each job's `description`. `markdown` (default) returns it as stored. `html` renders it to HTML on the fly: raw HTML in the source is escaped and only `http(s)`, `mailto` and relative URLs are kept.

    SearchWarning:
      type: object
      description: A non-fatal problem with an otherwise successful request.
      properties:
        code:
          type: string
          description: |-
            Stable machine-readable code. One of `companies_unmatched`, `companies_exclude_unmatched`, `companies_outside_sources`, `unknown_request_fields` (body fields on `POST /api/jobs/search`, or query parameters on `GET /api/jobs`, that the API did not recognize and ignored).
          example: companies_unmatched
        field:
          type: string
          nullable: true
          description: The request field the warning is about.
          example: companies.include
        message:
          type: string
          description: Human-readable explanation, safe to show an end user.

    MatchedCompany:
      type: object
      description: One company that a supplied name resolved to.
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: The exact stored display name — supply this for an unambiguous match.
        provider_id:
          type: string
          description: |-
            The ATS this company's jobs come from. Present so a caller pairing `companies` with `sources` can see when the two cannot overlap.
          example: workday
        website:
          type: string
          nullable: true
        is_enabled:
          type: boolean
          description: False when the company is no longer actively scraped.

    CompanyFilterDiagnostics:
      type: object
      description: |-
        How each `companies` entry resolved: the companies it matched, or that it matched nothing.
      properties:
        matched:
          type: array
          nullable: true
          items:
            type: object
            properties:
              query:
                type: string
                description: The name exactly as supplied, echoed back.
              companies:
                type: array
                items:
                  $ref: "#/components/schemas/MatchedCompany"
        unmatched:
          type: array
          nullable: true
          description: "`companies.include` names that matched no company."
          items:
            type: string
        exclude_unmatched:
          type: array
          nullable: true
          description: "`companies.exclude` names that matched nothing, and so excluded nothing."
          items:
            type: string

    InclusionExclusionFilter:
      type: object
      properties:
        include:
          type: array
          items:
            type: string
          nullable: true
          description: Values to include (matched case-insensitively)
        exclude:
          type: array
          items:
            type: string
          nullable: true
          description: Values to exclude (matched case-insensitively)

    CompanySizeBandFilter:
      type: object
      description: Include/exclude filter on company headcount bands (matched case-insensitively).
      properties:
        include:
          type: array
          items:
            type: string
            enum: ["1-10", "11-50", "51-200", "201-500", "501-1000", "1001-5000", "5001-10000", "10001+"]
          nullable: true
          description: Bands to include. A job matches if its company is in ANY of them.
        exclude:
          type: array
          items:
            type: string
            enum: ["1-10", "11-50", "51-200", "201-500", "501-1000", "1001-5000", "5001-10000", "10001+"]
          nullable: true
          description: Bands to exclude.

    CompanyRevenueBandFilter:
      type: object
      description: Include/exclude filter on company annual revenue bands (matched case-insensitively).
      properties:
        include:
          type: array
          items:
            type: string
            enum: [under-1m, 1m-10m, 10m-50m, 50m-100m, 100m-200m, 200m-1b, over-1b]
          nullable: true
          description: Bands to include. A job matches if its company is in ANY of them.
        exclude:
          type: array
          items:
            type: string
            enum: [under-1m, 1m-10m, 10m-50m, 50m-100m, 100m-200m, 200m-1b, over-1b]
          nullable: true
          description: Bands to exclude.

    RangeFilterInt:
      type: object
      properties:
        min:
          type: integer
          nullable: true
          minimum: 0
        max:
          type: integer
          nullable: true
          minimum: 0

    JobFeedRequest:
      type: object
      description: |
        Cursor-paginated feed request. The cursor keeps the first request's filters, `stable_scan` ordering and `batch_size`: filters sent with a cursor are ignored, and `batch_size` can be lowered but never raised. `description_format` is read from every request. Start a new scan to change the filters.
      properties:
        locations:
          type: array
          items:
            $ref: "#/components/schemas/LocationFilter"
          nullable: true
          description: |
            Structured location filters. A job matches if it matches ANY entry. Each entry routes to its most-specific populated level (city, then region, then country) and every level needs `country`. Use `{"country": "Remote"}` to match remote jobs.
        sources:
          type: array
          items:
            type: string
          nullable: true
          description: |-
            Restrict to these ATS provider ids (e.g. `greenhouse`, `lever`, `workday`). See [Sources](/sources) for every id.
        work_models:
          type: array
          items:
            type: string
            enum: [remote, hybrid, onsite]
          nullable: true
          description: "Filter by work model: remote, hybrid, onsite. null = all jobs"
        employment_types:
          type: array
          items:
            type: string
            enum: [full-time, part-time, contract, internship, freelance, temporary]
          nullable: true
          description: "Filter by employment type. null = all jobs"
        experience_levels:
          type: array
          items:
            type: string
            enum: [intern, entry, mid, senior, lead, executive]
          nullable: true
          description: "Filter by experience level. null = all jobs"
        languages:
          type: array
          items:
            type: string
          nullable: true
          description: |-
            Filter by description language, as ISO 639-1 codes (`en`, `de`, `fr`). Language names and locales (`English`, `en-US`) are mapped to their code. null = all jobs.
        exclude_sources:
          type: array
          items:
            type: string
          nullable: true
          description: |-
            Leave out jobs from these ATS provider IDs (e.g. `lever`, `workday`), normalized like `sources`. May be combined with `sources`: `sources: [greenhouse, lever]` with `exclude_sources: [lever]` returns Greenhouse jobs.
        exclude_work_models:
          type: array
          items:
            type: string
            enum: [remote, hybrid, onsite]
          nullable: true
          description: |-
            Leave out jobs with these work models. Jobs whose work model is unknown are kept.
        exclude_employment_types:
          type: array
          items:
            type: string
            enum: [full-time, part-time, contract, internship, freelance, temporary]
          nullable: true
          description: |-
            Leave out jobs with these employment types. Jobs whose employment type is unknown are kept.
        exclude_experience_levels:
          type: array
          items:
            type: string
            enum: [intern, entry, mid, senior, lead, executive]
          nullable: true
          description: |-
            Leave out jobs at these experience levels. Jobs whose experience level is unknown are kept.
        exclude_languages:
          type: array
          items:
            type: string
          nullable: true
          description: |-
            Leave out jobs whose description is in these languages, as ISO 639-1 codes; names and locales are mapped as for `languages`. Jobs whose language is unknown are kept.
        companies:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude companies, matched as on `POST /api/jobs/search`. Each entry is a Jobo company ID, a website domain or URL (`fedex.com`), or a whole company name (`FedEx`), case-insensitive and never a partial match.

            Entries are matched once, on the first page; the cursor keeps the matched company IDs. An entry that matches nothing is not a `400`: the first page's `warnings` lists `companies_unmatched` (include), `companies_exclude_unmatched` (exclude) or `companies_outside_sources` (matches on none of the requested `sources`). If the include list matches no company, or none on the requested `sources`, the first page has `jobs: []`, `has_more: false`, `next_cursor: null` and `estimated_total: 0`, and is not billed.
        job_categories:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude filter on the job's occupation category (`occupation.category`, e.g. `Healthcare`). Exact, case-insensitive match. `GET /api/occupations` lists every value. Jobs without an `occupation` never match an include list.
        job_subcategories:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude filter on the job's occupation subcategory (`occupation.subcategory`, e.g. `Nursing`). Exact, case-insensitive match. `GET /api/occupations` lists every value.
        occupation_ids:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude filter on occupation id (`occupation.id`, e.g. `esco:8d3e8aaa-791b-4c75-a465-f3f827028f50`). Use ids, not names: names can improve between taxonomy releases, ids don't. `GET /api/occupations` lists every id.
        industries:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude filter on the hiring company's industries (`company.industries`, e.g. `Software Development`), matched on the whole name, case-insensitively. Include keeps jobs whose company has ANY of the values; exclude drops jobs whose company has any of them.
        company_primary_industries:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude filter on the company's single primary industry (`primary_industry` on the company profile, `GET /api/companies/{id}`, e.g. `Finance`). Exact, case-insensitive match.
        company_categories:
          type: object
          allOf:
            - $ref: "#/components/schemas/InclusionExclusionFilter"
          nullable: true
          description: |-
            Include/exclude filter on the hiring company's business model (`categories` on the company profile, `GET /api/companies/{id}`), case-insensitive: `b2b`, `b2c`, `b2b2c`, `saas`, `marketplace`, `service-provider`. Include keeps jobs whose company has ANY of them; exclude drops jobs whose company has any of them.
        company_sizes:
          type: object
          allOf:
            - $ref: "#/components/schemas/CompanySizeBandFilter"
          nullable: true
          description: |-
            Include/exclude filter on the company's headcount band (`company_size` on the company profile, `GET /api/companies/{id}`). Companies of unknown size never match an include list and are kept by an exclude list. An unknown band is a `400`.
        company_revenues:
          type: object
          allOf:
            - $ref: "#/components/schemas/CompanyRevenueBandFilter"
          nullable: true
          description: |-
            Include/exclude filter on the company's annual revenue band (`revenue` on the company profile, `GET /api/companies/{id}`). Companies of unknown revenue never match an include list and are kept by an exclude list. An unknown band is a `400`.
        company_is_agency:
          type: boolean
          nullable: true
          description: |-
            `false` leaves out jobs that recruitment and staffing agencies post on behalf of other employers (`is_agency` on the company profile, `GET /api/companies/{id}`); `true` returns only theirs. Omit or null = all jobs.
        posted_after:
          type: string
          format: date-time
          nullable: true
          description: |
            Return jobs whose employer posting time or first-indexed time is on or after this UTC timestamp. Use `updated_after` when edits matter.
        include_reactivated:
          type: boolean
          default: false
          description: |
            Also return jobs that expired and were reactivated (rediscovered and re-indexed) on or after `posted_after`. A reactivated job keeps its original posting and creation dates, so `posted_after` alone never re-delivers it.

            No effect without `posted_after`. An `updated_after` sync doesn't need it: `updated_after` already returns reactivated jobs. With both timestamps set, a job must match both.
        description_format:
          type: string
          enum: [markdown, html]
          default: markdown
          description: |
            Format of each job's `description`. `markdown` (default) returns it as stored. `html` renders it to HTML on the fly: raw HTML in the source is escaped and only `http(s)`, `mailto` and relative URLs are kept. Read from every request, including cursor continuations.
        updated_after:
          type: string
          format: date-time
          nullable: true
          description: |
            Return jobs created, updated or re-indexed on or after this UTC timestamp, including jobs reactivated in that window. The watermark for incremental syncs: see [Sync a database](/guides/sync-a-database).
        stable_scan:
          type: boolean
          default: true
          description: |
            `true` (default) pages by creation time, so jobs updated or deleted mid-scan are never skipped or duplicated. `false` is the legacy update-recency order: its scans stop at 1,000,000 results with `409 feed_cursor_restart_required`.
        cursor:
          type: string
          nullable: true
          description: Opaque cursor from `next_cursor`. Omit on the first request.
        batch_size:
          type: integer
          default: 1000
          minimum: 1
          maximum: 1000

    ManagedJobFeedRequest:
      type: object
      description: |
        Cursor-paginated request for the managed feed: jobs from the companies you chose under **Managed Services → Job Scraping**, oldest first as with `stable_scan: true`. There is no `sources`, `stable_scan`, `exclude_` list, or occupation and company filter (`companies`, `job_categories`, `industries`, `company_sizes`, …). For those, stream a [saved feed](/guides/sync-a-database#saved-feeds) built from your managed feed.
      properties:
        locations:
          type: array
          items:
            $ref: "#/components/schemas/LocationFilter"
          nullable: true
          description: |
            Structured location filters. A job matches if it matches ANY entry. Each entry routes to its most-specific populated level (city, then region, then country) and every level needs `country`. Use `{"country": "Remote"}` to match remote jobs.
        work_models:
          type: array
          items:
            type: string
            enum: [remote, hybrid, onsite]
          nullable: true
          description: "Filter by work model: remote, hybrid, onsite. null = all jobs"
        employment_types:
          type: array
          items:
            type: string
            enum: [full-time, part-time, contract, internship, freelance, temporary]
          nullable: true
          description: "Filter by employment type. null = all jobs"
        experience_levels:
          type: array
          items:
            type: string
            enum: [intern, entry, mid, senior, lead, executive]
          nullable: true
          description: "Filter by experience level. null = all jobs"
        languages:
          type: array
          items:
            type: string
          nullable: true
          description: |-
            Filter by description language, as ISO 639-1 codes (`en`, `de`, `fr`). Language names and locales (`English`, `en-US`) are mapped to their code. null = all jobs.
        posted_after:
          type: string
          format: date-time
          nullable: true
          description: Return jobs whose employer posting time or first-indexed time is on or after this UTC timestamp. Use `updated_after` when edits matter.
        include_reactivated:
          type: boolean
          default: false
          description: |
            Also return jobs that expired and were reactivated (rediscovered and re-indexed) on or after `posted_after`. A reactivated job keeps its original posting and creation dates, so `posted_after` alone never re-delivers it.

            No effect without `posted_after`. An `updated_after` sync doesn't need it: `updated_after` already returns reactivated jobs. With both timestamps set, a job must match both.
        description_format:
          type: string
          enum: [markdown, html]
          default: markdown
          description: |
            Format of each job's `description`. `markdown` (default) returns it as stored. `html` renders it to HTML on the fly: raw HTML in the source is escaped and only `http(s)`, `mailto` and relative URLs are kept. Read from every request, including cursor continuations.
        updated_after:
          type: string
          format: date-time
          nullable: true
          description: |
            Return jobs created, updated or re-indexed on or after this UTC timestamp, including jobs reactivated in that window, plus every job of a company added to your managed job sources since then. The watermark for incremental syncs.
        cursor:
          type: string
          nullable: true
          description: |
            Opaque cursor from `next_cursor`. Omit on the first request. It keeps the first request's filters and `batch_size`: filters sent with it are ignored, and `batch_size` can be lowered but never raised.
        batch_size:
          type: integer
          default: 1000
          minimum: 1
          maximum: 1000

    SavedJobFeedRequest:
      type: object
      description: |
        Cursor-paginated export request for a saved feed. The feed's rules, edited in the dashboard's **Feed Builder**, decide what is in it. Only pagination, the incremental watermark and the description format are set per request.
      properties:
        cursor:
          type: string
          nullable: true
          description: |
            Opaque cursor from `next_cursor`. Omit on the first request. The cursor embeds the feed's revision, the batch size and `updated_after`, so those values sent alongside it are ignored — except `batch_size`, which a continuation may reduce but never raise.
        batch_size:
          type: integer
          default: 1000
          minimum: 1
          maximum: 1000
        updated_after:
          type: string
          format: date-time
          nullable: true
          description: |
            Only return jobs created, updated or re-indexed on or after this UTC timestamp — incremental watermark for syncs — plus every job of a company that joined the feed on or after it. Ignored on continuations.
        description_format:
          type: string
          enum: [markdown, html]
          default: markdown
          description: |
            Format of each job's `description`. `markdown` (default) returns it as stored. `html` renders it to HTML on the fly: raw HTML in the source is escaped and only `http(s)`, `mailto` and relative URLs are kept. Read from every request, including cursor continuations.

    LocationFilter:
      type: object
      properties:
        country:
          type: string
          nullable: true
          description: Country name or ISO country code (e.g. United States, US, USA). Case-insensitive; UK is also accepted.
        region:
          type: string
          nullable: true
        city:
          type: string
          nullable: true

    JobFeedResponse:
      type: object
      required: [jobs, next_cursor, has_more]
      properties:
        jobs:
          type: array
          items:
            $ref: "#/components/schemas/JobDto"
        next_cursor:
          type: string
          nullable: true
        has_more:
          type: boolean
        estimated_total:
          type: integer
          format: int64
          nullable: true
          description: |
            Estimated number of jobs the scan will deliver. Present on the first page of a scan only; null on continuation pages. Exact when 1,000 or fewer jobs match; above that an estimate that can run a few percent high.
        warnings:
          type: array
          nullable: true
          description: |-
            `POST /api/jobs/feed` only: non-fatal problems with the first page's request, e.g. a `companies` entry that matched no company (`companies_unmatched`, `companies_exclude_unmatched`, `companies_outside_sources`). Present on the first page only, and only when there is something to report. The same codes are on the `X-Jobo-Warnings` response header, comma-separated.
          items:
            $ref: "#/components/schemas/SearchWarning"

    SavedJobFeedResponse:
      type: object
      required: [jobs, next_cursor, has_more, feed_id, feed_revision]
      properties:
        jobs:
          type: array
          items:
            $ref: "#/components/schemas/JobDto"
        next_cursor:
          type: string
          nullable: true
        has_more:
          type: boolean
        estimated_total:
          type: integer
          format: int64
          nullable: true
          description: |
            Estimated number of jobs the scan will deliver. Present on the first page of a scan only; null on continuation pages.
        feed_id:
          type: string
          format: uuid
        feed_revision:
          type: integer
          description: |
            The feed's content revision when this page was read. It changes when the feed's source, conditions or excluded companies change, not on a rename. If it differs from your last full scan's revision, the feed selects different jobs: run a full scan.

    ExpiredJobIdsFeedResponse:
      type: object
      required: [job_ids, next_cursor, has_more]
      properties:
        job_ids:
          type: array
          items:
            type: string
            format: uuid
        next_cursor:
          type: string
          nullable: true
        has_more:
          type: boolean

    # ── Auto Apply ───────────────────────────────
    AutoApplyApiVersion:
      type: string
      description: Version of the Auto Apply API contract.
      enum: ["2026-08-31"]
      example: "2026-08-31"

    CreateAutoApplyApplicationRequest:
      type: object
      description: |
        Application target. Supply exactly one of `job_id` or `apply_url`.
      properties:
        job_id:
          type: string
          format: uuid
          description: Jobo job ID whose current apply URL should be used.
          example: 2c1ac0f5-8772-43d2-b5a8-26b44a3fa171
        apply_url:
          type: string
          format: uri
          description: Public HTTPS application URL handled by an active Auto Apply provider.
          example: https://boards.greenhouse.io/acme/jobs/4567890
      oneOf:
        - required: [job_id]
        - required: [apply_url]

    AutoApplySubmitAnswersRequest:
      type: object
      description: |
        The complete answer snapshot for the application's current answerable step. Validated synchronously; accepted exactly once per (step, correction round).
      required: [answers]
      properties:
        answers:
          type: array
          items:
            $ref: "#/components/schemas/AutoApplyAnswer"
        correction_round:
          type: integer
          minimum: 0
          nullable: true
          description: |
            Optional guard. If set and not the step's current correction round, the request fails with `409 stale_correction_round` instead of answering the wrong round.

    AutoApplyValidationProblem:
      allOf:
        - $ref: "#/components/schemas/ProblemDetails"
        - type: object
          required: [errors]
          properties:
            errors:
              type: array
              description: Per-field validation failures. Nothing was consumed.
              items:
                $ref: "#/components/schemas/AutoApplyCommandError"

    AutoApplyCreateConflictProblem:
      allOf:
        - $ref: "#/components/schemas/ProblemDetails"
        - type: object
          properties:
            limit:
              type: integer
              description: Your account's limit on concurrently active applications. Sent with `concurrency_limit_reached`.
            active:
              type: integer
              description: Applications currently active on your account. Sent with `concurrency_limit_reached`.

    AutoApplyApplicationStatus:
      type: string
      description: |
        Current application state. Answer `current_step` while `awaiting_answers`; `submitted`, `failed` and `canceled` are final.
      enum:
        - queued
        - running
        - awaiting_answers
        - submitted
        - failed
        - canceled

    AutoApplyApplicationSummary:
      type: object
      required:
        - api_version
        - id
        - job_id
        - apply_url
        - provider_id
        - provider_name
        - status
        - cancel_requested
        - status_updated_at
        - queued_at
        - started_at
        - submitted_at
        - failed_at
        - canceled_at
        - completed_at
        - created_at
      properties:
        api_version:
          $ref: "#/components/schemas/AutoApplyApiVersion"
        id:
          type: string
          format: uuid
          description: Stable application ID.
        job_id:
          type: string
          nullable: true
          description: Supplied Jobo job ID, or null when apply_url was supplied directly.
        apply_url:
          type: string
          format: uri
          description: Resolved application URL.
        provider_id:
          type: string
          description: Stable provider identifier selected at intake.
        provider_name:
          type: string
          description: Provider display name captured at intake.
        status:
          $ref: "#/components/schemas/AutoApplyApplicationStatus"
        cancel_requested:
          type: boolean
          description: Whether cancellation has been requested.
        status_updated_at:
          type: string
          format: date-time
        queued_at:
          type: string
          format: date-time
        started_at:
          type: string
          format: date-time
          nullable: true
        submitted_at:
          type: string
          format: date-time
          nullable: true
        failed_at:
          type: string
          format: date-time
          nullable: true
        canceled_at:
          type: string
          format: date-time
          nullable: true
        completed_at:
          type: string
          format: date-time
          nullable: true
          description: submitted_at, failed_at, or canceled_at, whichever is set.
        created_at:
          type: string
          format: date-time

    AutoApplyApplication:
      allOf:
        - $ref: "#/components/schemas/AutoApplyApplicationSummary"
        - type: object
          required:
            - provider_version
            - matcher_revision
            - catalog_revision
            - settings_revision
            - provider_receipt_id
            - submission_confirmation_kind
            - failure
            - canceled_by
            - steps
            - current_step
          properties:
            provider_version:
              type: string
              description: Provider implementation version captured at intake.
            matcher_revision:
              type: integer
              format: int64
            catalog_revision:
              type: integer
              format: int64
            settings_revision:
              type: integer
              format: int64
            provider_receipt_id:
              type: string
              nullable: true
              maxLength: 256
              description: |-
                Non-sensitive request or submission identifier issued by the provider, suitable for support tracing when present.
            submission_confirmation_kind:
              type: string
              nullable: true
              enum:
                - provider_receipt
                - confirmation_page
              description: Authoritative evidence used to confirm the submitted state.
            failure:
              type: object
              allOf:
                - $ref: "#/components/schemas/AutoApplyFailure"
              nullable: true
            canceled_by:
              type: string
              nullable: true
              description: Actor that completed cancellation, when applicable.
            steps:
              type: array
              items:
                $ref: "#/components/schemas/AutoApplyApplicationStep"
            current_step:
              type: object
              allOf:
                - $ref: "#/components/schemas/AutoApplyApplicationStep"
              nullable: true
              description: |-
                The step currently awaiting answers: the one to answer next. Null while the application is queued, running between steps, or terminal.

    AutoApplyApplicationList:
      type: object
      required: [api_version, items, next_cursor]
      properties:
        api_version:
          $ref: "#/components/schemas/AutoApplyApiVersion"
        items:
          type: array
          items:
            $ref: "#/components/schemas/AutoApplyApplicationSummary"
        next_cursor:
          type: string
          nullable: true
          description: Opaque cursor for the next page, or null on the final page.

    AutoApplyFailure:
      type: object
      required: [code, message, retryable]
      properties:
        code:
          type: string
          description: Stable machine-readable failure code.
          example: submission_unconfirmed
        message:
          type: string
          description: Safe human-readable failure detail.
        retryable:
          type: boolean
          description: Whether retrying can be considered safe.

    AutoApplyApplicationStep:
      type: object
      required:
        - id
        - sequence
        - status
        - correction_round
        - fields
        - answers
        - command_errors
        - created_at
        - completed_at
        - field_count
        - answer_count
      properties:
        id:
          type: string
          format: uuid
        sequence:
          type: integer
          minimum: 1
        status:
          type: string
          description: |
            Processing state of this step, such as `discovered`, `answered`, `correction_required` or `completed`. Diagnostic only: drive your loop from the application's `status` and `current_step`.
          example: answered
        correction_round:
          type: integer
          minimum: 0
        fields:
          type: array
          items:
            $ref: "#/components/schemas/AutoApplyField"
        answers:
          type: array
          description: Accepted answers for this step. Never reused by another application.
          items:
            $ref: "#/components/schemas/AutoApplyAnswer"
        command_errors:
          type: array
          description: |-
            What the employer's ATS rejected in the previous round, which is why this step is back for correction.
          items:
            $ref: "#/components/schemas/AutoApplyCommandError"
        created_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
          nullable: true
        answers_expire_at:
          type: string
          format: date-time
          nullable: true
          description: |-
            Deadline to answer this step while a live browser holds the form open: about 5 minutes, or 90 seconds for one-time verification codes. Past it, most applications keep waiting without a browser and this field becomes null; verification-code steps fail with `verification_timeout`, and providers that cannot resume fail with `answers_timeout`. Also null once the step is answered or completed. See [Deadlines](/api-reference/auto-apply/flow#4-deadlines).
        field_count:
          type: integer
          minimum: 0
        answer_count:
          type: integer
          minimum: 0

    AutoApplyGroupType:
      type: string
      description: Structural kind of a repeating group.
      enum:
        - education
        - work_experience
        - certification
        - website
        - language
        - skill
        - other

    AutoApplyField:
      description: |
        One form field to answer. Every variant has `field_id`, `type`, `label`, `required` and `requires_answer`. Only choice fields carry `options`, and only repeating groups carry `group_type`, `min_items`, `max_items` and `item_fields`. See [Field schema](/api-reference/auto-apply/schema).
      oneOf:
        - $ref: "#/components/schemas/AutoApplyScalarField"
        - $ref: "#/components/schemas/AutoApplyChoiceField"
        - $ref: "#/components/schemas/AutoApplyRepeatingGroupField"

    AutoApplyScalarField:
      type: object
      additionalProperties: false
      required:
        - field_id
        - type
        - label
        - required
        - requires_answer
      properties:
        field_id:
          type: string
          description: Stable within this application step; echo it in the answer.
        type:
          type: string
          enum: [text, textarea, checkbox, number, date, partial_date, file, unknown]
        label:
          type: string
        required:
          type: boolean
          description: Whether the ATS marks the field required.
        requires_answer:
          type: boolean
          description: Whether your answers must include this field, exactly once.
        constraints:
          type: object
          additionalProperties: {}
          minProperties: 1
          description: Present when the ATS advertises type-specific limits such as minimum_precision.
        sensitive:
          type: boolean
          enum: [true]
          description: Present as true for voluntary self-identification and similar sensitive fields; otherwise omitted.
        format:
          type: string
          minLength: 1
          description: Present for formats such as email, url, rich_text, and one_time_code.

    AutoApplyChoiceField:
      type: object
      additionalProperties: false
      required:
        - field_id
        - type
        - label
        - required
        - requires_answer
      properties:
        field_id:
          type: string
          description: Stable within this application step; echo it in the answer.
        type:
          type: string
          enum: [select, multi_select, radio, typeahead]
        label:
          type: string
        required:
          type: boolean
          description: Whether the ATS marks the field required.
        requires_answer:
          type: boolean
          description: Whether your answers must include this field, exactly once.
        options:
          type: array
          minItems: 1
          description: Present when the ATS advertises allowed values.
          items:
            $ref: "#/components/schemas/AutoApplyFieldOption"
        constraints:
          type: object
          additionalProperties: {}
          minProperties: 1
          description: Present when the ATS advertises type-specific limits such as minimum_precision.
        sensitive:
          type: boolean
          enum: [true]
          description: Present as true for voluntary self-identification and similar sensitive fields; otherwise omitted.
        format:
          type: string
          minLength: 1
          description: Present for formats such as email, url, rich_text, and one_time_code.

    AutoApplyRepeatingGroupField:
      type: object
      additionalProperties: false
      required:
        - field_id
        - type
        - label
        - required
        - requires_answer
        - group_type
        - item_fields
      properties:
        field_id:
          type: string
          description: Stable within this application step; echo it in the answer.
        type:
          type: string
          enum: [repeating_group]
        label:
          type: string
        required:
          type: boolean
          description: Whether the ATS marks the field required.
        requires_answer:
          type: boolean
          description: Whether your answers must include this field, exactly once.
        constraints:
          type: object
          additionalProperties: {}
          minProperties: 1
          description: Present when the ATS advertises type-specific limits.
        sensitive:
          type: boolean
          enum: [true]
          description: Present as true for fields that require sensitive-data handling; otherwise omitted.
        format:
          type: string
          minLength: 1
          description: Present when the provider advertises a nonempty format.
        group_type:
          $ref: "#/components/schemas/AutoApplyGroupType"
        min_items:
          type: integer
          minimum: 0
          maximum: 100
          description: Minimum complete items; present only for a repeating group when bounded.
        max_items:
          type: integer
          minimum: 0
          maximum: 100
          description: Provider limit, capped at 100; present only when bounded.
        item_fields:
          type: array
          description: Ordered child-field contract.
          items:
            $ref: "#/components/schemas/AutoApplyGroupItemField"

    AutoApplyFieldOption:
      type: object
      required: [value, label]
      properties:
        value:
          type: string
          description: Exact value the answer must use.
        label:
          type: string
          description: Display text shown by the ATS.

    AutoApplyGroupItemField:
      oneOf:
        - $ref: "#/components/schemas/AutoApplyScalarGroupItemField"
        - $ref: "#/components/schemas/AutoApplyChoiceGroupItemField"

    AutoApplyScalarGroupItemField:
      type: object
      additionalProperties: false
      required: [key, type, label, required]
      properties:
        key:
          type: string
          description: Exact object key to use in each group item.
        type:
          type: string
          enum: [text, textarea, checkbox, number, date, partial_date, file, unknown]
        label:
          type: string
        required:
          type: boolean
        constraints:
          type: object
          additionalProperties: {}
          minProperties: 1
          description: Present when the ATS advertises type-specific limits.

    AutoApplyChoiceGroupItemField:
      type: object
      additionalProperties: false
      required: [key, type, label, required]
      properties:
        key:
          type: string
          description: Exact object key to use in each group item.
        type:
          type: string
          enum: [select, multi_select, radio, typeahead]
        label:
          type: string
        required:
          type: boolean
        options:
          type: array
          minItems: 1
          description: Present when the ATS advertises allowed values.
          items:
            $ref: "#/components/schemas/AutoApplyFieldOption"
        constraints:
          type: object
          additionalProperties: {}
          minProperties: 1
          description: Present when the ATS advertises type-specific limits.

    AutoApplyTypeaheadValue:
      type: object
      required: [query, selection]
      properties:
        query:
          type: string
          description: Text used to search the ATS typeahead.
        selection:
          type: object
          required: [value, label]
          properties:
            value:
              type: string
              description: Exact result value returned by the ATS.
            label:
              type: string
              description: Result display label.
      example:
        query: MIT
        selection:
          value: school_123
          label: Massachusetts Institute of Technology

    AutoApplyFileValue:
      type: object
      required: [url, filename]
      properties:
        url:
          type: string
          format: uri
          description: Publicly reachable HTTPS URL (port 443) that Jobo can download without cookies or a login page, such as a signed object-storage URL. Keep it valid through upload and retries.
        filename:
          type: string
          minLength: 1
        content_type:
          type: string
          nullable: true
          example: application/pdf
      example:
        url: https://files.example.com/signed/resume.pdf
        filename: resume.pdf
        content_type: application/pdf

    AutoApplyScalarAnswerValue:
      description: |
        Typed scalar value. text, textarea, select, and radio use strings; checkbox uses a boolean; number uses a JSON number; multi_select uses an array of option-value strings; date and partial_date use date strings; typeahead and file use their structured objects.
      anyOf:
        - type: string
        - type: number
        - type: boolean
        - type: array
          items:
            type: string
        - $ref: "#/components/schemas/AutoApplyTypeaheadValue"
        - $ref: "#/components/schemas/AutoApplyFileValue"

    AutoApplyGroupItemValue:
      description: |
        Typed value for one advertised item field. Null is used only when the advertised item contract permits it.
      anyOf:
        - $ref: "#/components/schemas/AutoApplyScalarAnswerValue"
        - type: string
          nullable: true

    AutoApplyRepeatingGroupItem:
      type: object
      description: |
        One complete repeating-group item. Keys not advertised in item_fields are rejected; each value must match the advertised child type.
      additionalProperties:
        $ref: "#/components/schemas/AutoApplyGroupItemValue"

    AutoApplyEducationGroupValue:
      type: array
      maxItems: 100
      description: |
        Ordered education entries. Follow the field's `item_fields` exactly; these common properties illustrate the typed shape.
      items:
        allOf:
          - $ref: "#/components/schemas/AutoApplyRepeatingGroupItem"
          - type: object
            properties:
              school:
                anyOf:
                  - type: string
                  - $ref: "#/components/schemas/AutoApplyTypeaheadValue"
              degree:
                type: string
              field_of_study:
                type: string
              start_date:
                type: string
                pattern: '^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$'
              end_date:
                type: string
                nullable: true
                pattern: '^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$'
              graduation_date:
                type: string
                nullable: true
                pattern: '^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$'
              description:
                type: string
      example:
        - school:
            query: MIT
            selection:
              value: school_123
              label: Massachusetts Institute of Technology
          degree: BS
          field_of_study: Computer Science
          start_date: "2017-09"
          graduation_date: "2021-06"

    AutoApplyWorkExperienceGroupValue:
      type: array
      maxItems: 100
      description: |
        Ordered work-history entries. Follow the field's `item_fields` exactly; these common properties illustrate the typed shape.
      items:
        allOf:
          - $ref: "#/components/schemas/AutoApplyRepeatingGroupItem"
          - type: object
            properties:
              company:
                anyOf:
                  - type: string
                  - $ref: "#/components/schemas/AutoApplyTypeaheadValue"
              title:
                type: string
              location:
                type: string
              start_date:
                type: string
                pattern: '^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$'
              end_date:
                type: string
                nullable: true
                pattern: '^\d{4}(?:-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?)?$'
              is_current:
                type: boolean
              description:
                type: string
      example:
        - company: Acme
          title: Engineer
          location: Remote
          start_date: "2021-03"
          end_date: null
          is_current: true
          description: Built internal systems.

    AutoApplyGenericRepeatingGroupValue:
      type: array
      maxItems: 100
      description: Ordered certification, website, language, skill, or other group entries.
      items:
        $ref: "#/components/schemas/AutoApplyRepeatingGroupItem"

    AutoApplyAnswerValue:
      description: |
        The JSON shape is set by the field's `type`. `education` and `work_experience` groups take the typed arrays shown here; other groups follow their `item_fields`. See [Field schema](/api-reference/auto-apply/schema).
      anyOf:
        - $ref: "#/components/schemas/AutoApplyScalarAnswerValue"
        - $ref: "#/components/schemas/AutoApplyEducationGroupValue"
        - $ref: "#/components/schemas/AutoApplyWorkExperienceGroupValue"
        - $ref: "#/components/schemas/AutoApplyGenericRepeatingGroupValue"

    AutoApplyAnswer:
      type: object
      required: [field_id, value]
      properties:
        field_id:
          type: string
          description: Exact `field_id` from the current step's `fields`.
        value:
          $ref: "#/components/schemas/AutoApplyAnswerValue"

    AutoApplyCommandError:
      type: object
      required: [field_id, item_index, field_key, code, message]
      properties:
        field_id:
          type: string
          nullable: true
        item_index:
          type: integer
          minimum: 0
          nullable: true
          description: Zero-based index for a repeating-group error.
        field_key:
          type: string
          nullable: true
          description: Child key for a repeating-group error.
        code:
          type: string
          description: Stable validation error code.
          example: invalid_option
        message:
          type: string

    GeocodeResultItem:
      type: object
      properties:
        input:
          type: string
        succeeded:
          type: boolean
        locations:
          type: array
          items:
            $ref: "#/components/schemas/GeocodedLocation"
        method:
          type: string
          nullable: true
          enum: [cache, remote_keyword, admin-code, admin-name, jp-address, photon-direct, photon-best, cache_negative, no_match, timeout, unavailable, null]
          description: |-
            Which branch produced the result: diagnostic only, so branch on `succeeded`, because new values can appear. `null` for a placeholder or an input with no letters or digits. See [The method field](/guides/resolve-locations#the-method-field).
        error:
          type: string
          nullable: true

    GeocodedLocation:
      type: object
      properties:
        city:
          type: string
          nullable: true
        region:
          type: string
          nullable: true
        country:
          type: string
          nullable: true
        display_name:
          type: string
          nullable: true
        country_code:
          type: string
          nullable: true
        latitude:
          type: number
          format: double
          nullable: true
        longitude:
          type: number
          format: double
          nullable: true
        fuzzy_confidence:
          type: number
          format: double
          nullable: true
          description: |-
            Token-overlap confidence (0.0–1.0) between the original input and the matched place's canonical labels. Null for cache hits and `remote_keyword` results.

    # ── Occupation DTOs ─────────────────────────
    OccupationTaxonomyResponse:
      type: object
      description: The occupation taxonomy, categories first.
      required: [version, categories]
      properties:
        version:
          type: string
          description: |-
            Changes when occupations or the hierarchy change. Display names can improve without a new version.
          example: bc3b8e92359c22b8
        categories:
          type: array
          items:
            $ref: "#/components/schemas/OccupationCategoryDto"

    OccupationCategoryDto:
      type: object
      required: [name, subcategories]
      properties:
        name:
          type: string
          description: The value `job_categories` filters on, and a job's `occupation.category`.
          example: Healthcare
        subcategories:
          type: array
          items:
            $ref: "#/components/schemas/OccupationSubcategoryDto"

    OccupationSubcategoryDto:
      type: object
      required: [name, occupations]
      properties:
        name:
          type: string
          description: The value `job_subcategories` filters on, and a job's `occupation.subcategory`.
          example: Nursing
        occupations:
          type: array
          items:
            $ref: "#/components/schemas/OccupationListItemDto"

    OccupationListItemDto:
      type: object
      required: [id, name, isco_code, onet_soc_codes]
      properties:
        id:
          type: string
          description: |
            Stable id (`esco:<uuid>` or `jobo:<slug>`): the value `occupation_ids` filters on, and a job's `occupation.id`.
          example: esco:8d3e8aaa-791b-4c75-a465-f3f827028f50
        name:
          type: string
          description: Display name. May improve over time.
          example: Registered Nurse
        isco_code:
          type: string
          nullable: true
          description: ISCO-08 unit group.
          example: "2221"
        onet_soc_codes:
          type: array
          items:
            type: string
          description: O\*NET-SOC 2019 codes. Empty when O\*NET has no equivalent.
          example: ["29-1141.00"]

    CreateMailboxRequest:
      type: object
      required: [provider, address]
      properties:
        provider:
          type: string
          enum: [imap, outlook]
          description: |-
            `imap` connects with a host and credentials; `outlook` starts the hosted Microsoft OAuth consent flow.
        address:
          type: string
          format: email
          description: The mailbox address. Unique per account; lowercased.
        imap_host:
          type: string
          nullable: true
          description: Required when `provider` is `imap`. The IMAP port must be 993 or 143.
        imap_port:
          type: integer
          nullable: true
          default: 993
        imap_security:
          type: string
          enum: [ssl, starttls]
          nullable: true
          default: ssl
        username:
          type: string
          nullable: true
          description: Defaults to address.
        password:
          type: string
          nullable: true
          description: |-
            Required for provider imap (use an app password where the provider offers them). Encrypted at rest, never returned.

    Mailbox:
      type: object
      required: [api_version, id, provider, address, status, created_at, updated_at]
      properties:
        api_version:
          $ref: "#/components/schemas/AutoApplyApiVersion"
        id:
          type: string
          format: uuid
        provider:
          type: string
          enum: [imap, outlook]
        address:
          type: string
          format: email
        status:
          type: string
          enum: [pending, connected, error]
          description: |-
            - `pending`: an `outlook` mailbox awaiting consent.
            - `connected`: the last connectivity check succeeded.
            - `error`: the last connectivity check failed; see `last_error`.
        imap_host:
          type: string
          nullable: true
        imap_port:
          type: integer
          nullable: true
        imap_security:
          type: string
          nullable: true
        username:
          type: string
          nullable: true
        authorization_url:
          type: string
          format: uri
          nullable: true
          description: |-
            Only on a pending outlook mailbox: open this URL (or send it to the mailbox owner) to grant consent via Microsoft.
        last_error:
          type: string
          nullable: true
        connected_at:
          type: string
          format: date-time
          nullable: true
        last_checked_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time

    MailboxList:
      type: object
      required: [api_version, mailboxes]
      properties:
        api_version:
          $ref: "#/components/schemas/AutoApplyApiVersion"
        mailboxes:
          type: array
          items:
            $ref: "#/components/schemas/Mailbox"

    MailboxVerificationRequest:
      type: object
      required: [application_id]
      properties:
        application_id:
          type: string
          format: uuid
          description: |-
            The application whose verification email should be found. Its apply host and provider give the extraction its context.
        wait_seconds:
          type: integer
          nullable: true
          default: 45
          minimum: 5
          maximum: 75
          description: |-
            How long to keep polling before returning `status: timeout`. Values outside 5–75 are clamped to that range.
        type:
          type: string
          enum: [code, link, confirmation, any]
          nullable: true
          default: any
          description: |-
            What the verification email must carry:

            - `code`: a one-time code.
            - `link`: a confirmation link, which Jobo clicks server-side.
            - `confirmation`: a positive post-submit acknowledgment. No link is visited.
            - `any` (default): a code or a link, not a bare acknowledgment.

    MailboxVerificationResult:
      type: object
      required: [api_version, status, waited_seconds]
      properties:
        api_version:
          $ref: "#/components/schemas/AutoApplyApiVersion"
        status:
          type: string
          enum: [found, timeout]
          description: A `timeout` means no matching verification email arrived within `wait_seconds`. It is an expected outcome, not an error.
        type:
          type: string
          enum: [code, link, confirmation]
          nullable: true
          description: What email evidence was found. Only present when status is found.
        code:
          type: string
          nullable: true
          description: The verification code. Only present when type is code.
        link:
          type: string
          format: uri
          nullable: true
          description: The confirmation link found in the email. Only present when type is link.
        link_visited:
          type: boolean
          nullable: true
          description: |-
            Whether Jobo successfully clicked the confirmation link server-side. When false, click the returned link yourself.
        link_http_status:
          type: integer
          nullable: true
          description: Final HTTP status of the server-side click, when one was made.
        email_from:
          type: string
          nullable: true
        email_subject:
          type: string
          nullable: true
        email_received_at:
          type: string
          format: date-time
          nullable: true
        waited_seconds:
          type: integer
        diagnostics:
          type: object
          allOf:
            - $ref: "#/components/schemas/MailboxVerificationDiagnostics"
          nullable: true
          description: |-
            What the poll actually observed. Present on every response so a timeout can be told apart: nothing arrived, something arrived that was not the evidence, or the extraction model was unavailable.
      example:
        api_version: "2026-08-31"
        status: found
        type: code
        code: AB12CD34
        email_from: no-reply@greenhouse.io
        email_subject: Your verification code
        email_received_at: "2026-08-15T09:30:12Z"
        waited_seconds: 9
        diagnostics:
          searched_since: "2026-08-15T09:28:00Z"
          folders: [inbox, junkemail]
          polls: 4
          fetch_failures: 0
          messages_seen: 1
          latest_message_received_at: "2026-08-15T09:30:12Z"
          latest_message_from_domain: greenhouse.io
          latest_message_subject: Your verification code
          extraction_attempts: 1
          extraction_failures: 0
          extraction_rejected: 0
          last_extraction_reason: null
          matched_by: llm

    MailboxVerificationDiagnostics:
      type: object
      required: [searched_since, folders, polls, fetch_failures, messages_seen, extraction_attempts, extraction_failures, extraction_rejected]
      properties:
        searched_since:
          type: string
          format: date-time
          description: Lower bound of the mailbox search. Shortly before the application started, but never more than 30 minutes before the request.
        folders:
          type: array
          items:
            type: string
          description: Mailbox folders the poll read, e.g. inbox and junkemail.
        polls:
          type: integer
          description: How many times the mailbox was read.
        fetch_failures:
          type: integer
          description: Mailbox reads that failed transiently (throttling, 5xx, network errors) and were skipped.
        messages_seen:
          type: integer
          description: Distinct messages newer than the application that the poll saw at all.
        latest_message_received_at:
          type: string
          format: date-time
          nullable: true
        latest_message_from_domain:
          type: string
          nullable: true
          description: Sender domain of the newest message seen — never the display name or local part.
        latest_message_subject:
          type: string
          nullable: true
          description: Subject of the newest message seen, with any code-like token masked.
        extraction_attempts:
          type: integer
          description: Extraction rounds handed to the model.
        extraction_failures:
          type: integer
          description: Rounds where the model gave no usable answer (unavailable, throttled, malformed).
        extraction_rejected:
          type: integer
          description: |-
            Rounds where the model answered but nothing was accepted — it reported no evidence for this application, or its answer failed re-validation.
        last_extraction_reason:
          type: string
          nullable: true
          description: |-
            Why the last empty extraction round found nothing, such as `not_found`, `confidence` or `code_not_verbatim`. Null when no round came back empty. On a `found` response, it says what delayed the match.
        matched_by:
          type: string
          enum: [llm, pattern]
          nullable: true
          description: |-
            How the evidence was matched, when `status` is `found`:

            - `llm`: the extraction model found it.
            - `pattern`: the model found nothing, and a strict pattern for the application's provider matched a one-time code.
