openapi: 3.1.0
info:
  title: erecruit API
  version: "1.0.0"
  summary: Australian Government ICT opportunities and the job ads they match.
  description: |
    erecruit links Australian Government ICT procurement notices (BuyICT ICT
    Labour Hire, `LH-*`) to the live job ads tender-panel agencies are running, so
    you can tell which contract sits behind which ad.

    **This document is written by hand and is the contract.** It lives at
    `apps/web/public/openapi.yaml`; the routes are in
    `apps/web/src/app/api/v1/`. A test
    (`apps/web/src/lib/apiV1-contract.test.ts`) fails if a documented path has no
    route, if a route is undocumented, or if a route stops applying the plan gate
    — so this file cannot quietly drift from what the server does.

    ## Access

    API access is part of the **Agency** plan. Recruiter and free accounts get
    `402 upgrade_required`; anonymous callers get `401 unauthorized`.

    Send a key from the dashboard (`/user/api`) as a bearer token:

    ```
    curl -H "Authorization: Bearer <your-key>" \
      https://erecruit.io/api/v1/opportunities?limit=10
    ```

    Keys have no scopes and no expiry: a key reads everything the account may
    read. Revoke it in the dashboard — revocation is immediate.

    ## Reading a response

    Every list endpoint returns the same envelope:

    ```json
    {
      "data": [],
      "pagination": {
        "limit": 50, "offset": 0, "total": 79,
        "count": 50, "hasMore": true, "nextOffset": 50
      }
    }
    ```

    `total` is the size of the whole set, not of this page.

    ## What this API does not do

    Stated because a missing capability that is not written down gets assumed:

    - **It is read-only.** There is no way to create or change anything, and no
      upload endpoint. Resume upload stays in the dashboard, where the consent
      record is written.
    - **It returns no resume contents.** `/api/v1/resumes` lists id, filename,
      status, size, mime and date. The CV text and any candidate contact details
      are personal data and are never returned by any endpoint.
    - **There is no filtering or search** yet — only `limit` and `offset`. Paging
      is applied to the full result set server-side, so `total` is exact.
    - **There is no rate limit on the API.** The in-process limiter the sign-in
      forms use is keyed on IP and lives per server instance, so it would neither
      limit across instances nor suit an integration that shares one IP. It is
      deliberately not applied here; treat the API as best-effort until a per-key
      limit exists.
    - **There is no webhook.** Poll the list endpoints.

    ## Deprecated paths

    `GET /api/opportunities` and `GET /api/matches` still work for keys already
    pointed at them. They return the older body (an object keyed by resource, not
    the `data`/`pagination` envelope), are unpaged and unsorted, and answer with
    `Deprecation: true` and a `Link: rel="successor-version"` header. Move to
    `/api/v1/`.
  contact:
    url: https://erecruit.io/contact
servers:
  - url: https://erecruit.io
    description: Production
  - url: http://localhost:3000
    description: Local development
tags:
  - name: Opportunities
    description: Government ICT procurement notices.
  - name: Matches
    description: Contract-to-ad pairings, with the evidence behind each score.
  - name: Agencies
    description: Recruitment agencies and how many ads they are running.
  - name: Contracts
    description: Every contract with a served page, including recently closed ones.
  - name: Resumes
    description: The key owner's own CVs and what they were matched to.
security:
  - apiKey: []
paths:
  /api/v1/opportunities:
    get:
      tags:
        - Opportunities
      operationId: listOpportunities
      summary: List open contracts
      description: |
        Every open contract, closing soonest first. A contract with no published
        close date sorts last, not first.

        Rows are the full record, including `description` and `skills` — the
        dashboard's own list views drop those for speed, but this is the paid
        contract and keeps them.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of open contracts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpportunityPage"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/UpgradeRequired"
  /api/v1/opportunities/{externalId}:
    get:
      tags:
        - Opportunities
      operationId: getOpportunity
      summary: Get one contract
      description: |
        A single contract by its BuyICT id (for example `LH-07659`), whatever its
        status. A closed contract is still served: the matches it already produced
        stay meaningful after it closes.
      parameters:
        - name: externalId
          in: path
          required: true
          description: The BuyICT notice id, `LH-` prefixed.
          schema:
            type: string
            examples:
              - LH-07659
      responses:
        "200":
          description: The contract.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: "#/components/schemas/Opportunity"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/UpgradeRequired"
        "404":
          $ref: "#/components/responses/NotFound"
  /api/v1/matches:
    get:
      tags:
        - Matches
      operationId: listMatches
      summary: List contract-to-ad matches
      description: |
        Every contract↔ad pairing, highest score first, then by id so paging sees
        each row exactly once.

        `evidence` carries the signal breakdown the score was computed from, and
        `jobAdDescription` the ad's own text — both are included here and omitted
        from the dashboard's list views.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of matches.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MatchPage"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/UpgradeRequired"
  /api/v1/agencies:
    get:
      tags:
        - Agencies
      operationId: listAgencies
      summary: List agencies with ad counts
      description: |
        Live and total ad counts per agency, by slug.

        `live` means anything the liveness re-check has not marked `expired`. An
        ad starts as `unknown` and is live until proven dead, so `live` is not a
        count of ads whose liveness is exactly `live`.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of agency counts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgencyPage"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/UpgradeRequired"
  /api/v1/contracts:
    get:
      tags:
        - Contracts
      operationId: listContracts
      summary: List contracts, open and recently closed
      description: |
        One row per contract that still has a served page: every open one, plus
        the closed ones inside the visibility window the `/buyers` and `/spend`
        hubs use. Closing soonest first.

        `invitation` is BuyICT's own label. Read it as a string —
        `"Open to all"` is non-empty and means the opposite of invitation-only,
        and on ICT Labour Hire invitation-only is the norm.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of contracts.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ContractPage"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/UpgradeRequired"
  /api/v1/resumes:
    get:
      tags:
        - Resumes
      operationId: listResumes
      summary: List the key owner's CVs
      description: |
        The key owner's own resumes, newest first. Metadata only — no CV text.

        Organisation (team workspace) resumes are not listed: they belong to the
        organisation, and who on a team may read them is a decision this version
        does not make.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          description: A page of resume metadata.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResumePage"
        "400":
          $ref: "#/components/responses/InvalidRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/UpgradeRequired"
  /api/v1/resumes/{id}/matches:
    get:
      tags:
        - Resumes
      operationId: getResumeMatches
      summary: Get what a CV was matched to
      description: |
        The opportunities a CV was matched to, highest score first, with the
        evidence behind each score.

        The same ownership rule the dashboard applies decides access: a resume
        owned by the key's user, or an organisation resume where that user is a
        member. A resume that does not exist and one this key may not read answer
        identically, so this endpoint cannot be used to discover which resume ids
        are real.
      parameters:
        - name: id
          in: path
          required: true
          description: Resume id, from `/api/v1/resumes`.
          schema:
            type: string
      responses:
        "200":
          description: The resume and its matches.
          content:
            application/json:
              schema:
                type: object
                required:
                  - data
                properties:
                  data:
                    $ref: "#/components/schemas/ResumeMatches"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/UpgradeRequired"
        "404":
          $ref: "#/components/responses/NotFound"
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: |
        A key created at `/user/api`. Send it as
        `Authorization: Bearer <key>`. Only a SHA-256 digest is stored, so a lost
        key cannot be recovered — revoke it and create another.
  parameters:
    Limit:
      name: limit
      in: query
      required: false
      description: Rows per page. Out of range is a 400, never a silent clamp.
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50
    Offset:
      name: offset
      in: query
      required: false
      description: Rows to skip. Past the end returns an empty page.
      schema:
        type: integer
        minimum: 0
        default: 0
  responses:
    InvalidRequest:
      description: A query parameter was not usable.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            badLimit:
              value:
                error:
                  code: invalid_request
                  message: limit must be between 1 and 200.
    Unauthorized:
      description: No key, or a key that is not recognised.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            anonymous:
              value:
                error:
                  code: unauthorized
                  message: Send a valid key as `Authorization: Bearer <key>`, or sign in.
    UpgradeRequired:
      description: The caller is known, but their plan does not include API access.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            recruiter:
              value:
                error:
                  code: upgrade_required
                  message: API access is part of the Agency plan. Upgrade to continue.
    NotFound:
      description: No such record, or none this key may read.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              enum:
                - unauthorized
                - upgrade_required
                - invalid_request
                - not_found
            message:
              type: string
              description: Human-readable, safe to show to a user.
    Pagination:
      type: object
      required:
        - limit
        - offset
        - total
        - count
        - hasMore
        - nextOffset
      properties:
        limit:
          type: integer
        offset:
          type: integer
        total:
          type: integer
          description: Rows in the whole result set, before paging.
        count:
          type: integer
          description: Rows in this response.
        hasMore:
          type: boolean
        nextOffset:
          type:
            - integer
            - "null"
          description: Pass as `offset` for the next page; null at the end.
    Opportunity:
      type: object
      description: A government procurement notice.
      required:
        - id
        - externalId
        - title
        - status
      properties:
        id:
          type: string
          description: Internal id. Stable, but `externalId` is the portable key.
        externalId:
          type: string
          description: BuyICT notice id, always `LH-` prefixed. The scope marker.
        source:
          type: string
          description: Where the notice came from, for example `buyict`.
        title:
          type: string
        role:
          type: [string, "null"]
          description: Normalised role title.
        quantity:
          type: [integer, "null"]
        buyer:
          type: [string, "null"]
        location:
          type: [string, "null"]
        clearance:
          type: [string, "null"]
          description: Baseline, NV1 or NV2.
        status:
          type: string
          description: "`open` or `closed`."
        description:
          type: [string, "null"]
          description: The notice's own text.
        skills:
          type: array
          items:
            type: string
        invitation:
          type: [string, "null"]
          description: "BuyICT's label: `Invited sellers` or `Open to all`."
        invitedSpecialisation:
          type: [string, "null"]
        workArrangement:
          type: [string, "null"]
          description: Onsite, Remote or Hybrid.
        offeringCategories:
          type: array
          items:
            type: string
        publishedAt:
          type: [string, "null"]
          format: date-time
        closesAt:
          type: [string, "null"]
          format: date-time
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    OpportunityPage:
      type: object
      required:
        - data
        - pagination
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Opportunity"
        pagination:
          $ref: "#/components/schemas/Pagination"
    Match:
      type: object
      description: |
        A contract↔ad pairing. Note the naming: `opportunityId`, `jobAdId` and
        `agencyId` carry the *external* identifiers (`LH-07659`, the ad's own id
        or URL hash, and the agency slug), not the internal ones.
      required:
        - id
        - opportunityId
        - jobAdId
        - agencyId
        - score
        - confidence
      properties:
        id:
          type: string
        opportunityId:
          type: string
          description: The contract's `externalId`, for example `LH-07659`.
        jobAdId:
          type: string
          description: The ad's `externalId` (a site id or a URL hash).
        agencyId:
          type: string
          description: The agency's slug.
        agencyName:
          type: string
        score:
          type: number
          format: float
        confidence:
          type: string
          description: "`high`, `medium` or `low`."
        status:
          type: string
          description: "`suggested`, `accepted` or `rejected`."
        opportunityTitle:
          type: string
        jobAdTitle:
          type: string
        jobAdUrl:
          type: string
          format: uri
        jobAdLocation:
          type: [string, "null"]
        jobAdClearance:
          type: [string, "null"]
        jobAdPostedAt:
          type: [string, "null"]
          format: date-time
        jobAdLiveness:
          type: [string, "null"]
          description: "`live`, `expired` or `unknown`."
        feedback:
          type: [string, "null"]
          description: "Recruiter verdict on the pairing: `correct` or `wrong`."
        evidence:
          type: [object, "null"]
          description: The signal breakdown the score was computed from.
        jobAdDescription:
          type: [string, "null"]
    MatchPage:
      type: object
      required:
        - data
        - pagination
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Match"
        pagination:
          $ref: "#/components/schemas/Pagination"
    AgencyAdCounts:
      type: object
      required:
        - slug
        - total
        - live
      properties:
        slug:
          type: string
        total:
          type: integer
          description: Every ad on record for this agency.
        live:
          type: integer
          description: Ads not marked `expired`.
    AgencyPage:
      type: object
      required:
        - data
        - pagination
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/AgencyAdCounts"
        pagination:
          $ref: "#/components/schemas/Pagination"
    Contract:
      type: object
      required:
        - externalId
        - title
        - status
      properties:
        externalId:
          type: string
        title:
          type: string
        role:
          type: [string, "null"]
        buyer:
          type: [string, "null"]
        location:
          type: [string, "null"]
        clearance:
          type: [string, "null"]
        invitation:
          type: [string, "null"]
        status:
          type: string
        closesAt:
          type: [string, "null"]
          format: date-time
        updatedAt:
          type: [string, "null"]
          format: date-time
    ContractPage:
      type: object
      required:
        - data
        - pagination
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Contract"
        pagination:
          $ref: "#/components/schemas/Pagination"
    Resume:
      type: object
      description: Resume metadata. The CV text is never returned.
      required:
        - id
        - filename
        - status
        - createdAt
      properties:
        id:
          type: string
        filename:
          type: string
        status:
          type: string
          description: Processing state, for example `pending` or `processed`.
        size:
          type: integer
          description: Bytes.
        mime:
          type: string
        createdAt:
          type: string
          format: date-time
    ResumePage:
      type: object
      required:
        - data
        - pagination
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Resume"
        pagination:
          $ref: "#/components/schemas/Pagination"
    ResumeMatch:
      type: object
      required:
        - id
        - opportunityExternalId
        - opportunityTitle
        - score
        - tier
        - createdAt
      properties:
        id:
          type: string
        opportunityExternalId:
          type: string
        opportunityTitle:
          type: string
        score:
          type: number
          format: float
        tier:
          type: string
          description: Confidence tier for this CV match.
        status:
          type: string
        createdAt:
          type: string
          format: date-time
        evidence:
          type: [object, "null"]
          description: Why this CV matched this contract.
    ResumeMatches:
      type: object
      required:
        - resume
        - matches
      properties:
        resume:
          type: object
          required:
            - id
            - filename
          properties:
            id:
              type: string
            filename:
              type: string
        matches:
          type: array
          items:
            $ref: "#/components/schemas/ResumeMatch"
