openapi: 3.1.0
info:
  title: Sorce API
  version: "1.0"
  description: >
    Programmatic job management for partners on Sorce. Authenticate with a
    company-bound partner key. All endpoints are served from
    https://www.sorce.jobs/api/external/v1.
servers:
  - url: https://www.sorce.jobs/api/external/v1
security:
  - bearerAuth: []
tags:
  - name: Locations
    description: Canonical place lookup. Search first, reference by id.
  - name: Jobs
    description: >
      Partner job management. Requires a partner key bound to your company.
      Other companies' jobs 404.

paths:
  /locations/search:
    get:
      tags: [Locations]
      operationId: searchLocations
      summary: Search locations
      description: >
        Canonical place autocomplete. Job locations must reference a
        suggestion's `locationId`. Free-typed places are rejected.
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          example: San Francisco
        - name: country
          in: query
          required: false
          schema: { type: string, default: United States }
      responses:
        "200":
          description: Matching places, best first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/LocationSuggestion" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /jobs:
    post:
      tags: [Jobs]
      operationId: createJob
      summary: Create a job
      description: >
        Creates a job for your company. Any subset of fields is accepted;
        omitted fields stay empty and the job is saved as a DRAFT. With
        `"publish": true` the job goes live in the same transaction,
        provided it passes the publish gates (completeness, terms accepted
        in the dashboard, card on file); if any gate fails, nothing is
        created.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/JobInput"
                - type: object
                  properties:
                    publish:
                      type: boolean
                      default: false
                      description: Publish atomically after creation.
      responses:
        "201":
          description: The created job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentMethodRequired" }
        "409": { $ref: "#/components/responses/AgreementOutdated" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /jobs/{id}:
    get:
      tags: [Jobs]
      operationId: getJob
      summary: Get a job
      parameters: [{ $ref: "#/components/parameters/jobId" }]
      responses:
        "200":
          description: The job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Jobs]
      operationId: updateJob
      summary: Update a job
      description: >
        Partial update; only the fields you send change. The arrays
        (`locations`, `qualifications`, `questions`, `budgets`) are
        replace-sets: what you send becomes the entire new set. Live
        (ACTIVE/PAUSED) jobs are re-validated after the patch and the whole
        change is rejected if it would leave the listing incomplete. Once a
        job has applications, `qualifications` and `questions` are frozen.
      parameters: [{ $ref: "#/components/parameters/jobId" }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/JobInput" }
      responses:
        "200":
          description: The updated job.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: JOB_NOT_EDITABLE (closed/expired) or JOB_FROZEN (has applications).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /jobs/{id}/publish:
    post:
      tags: [Jobs]
      operationId: publishJob
      summary: Publish a draft
      description: >
        DRAFT → ACTIVE. Runs the three publish gates: completeness
        (422 with a field map), terms accepted in the dashboard
        (409 AGREEMENT_OUTDATED), and a card on file (402). The listing
        runs for 30 days.
      parameters: [{ $ref: "#/components/parameters/jobId" }]
      responses:
        "200":
          description: The job, now ACTIVE.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentMethodRequired" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/AgreementOutdated" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /jobs/{id}/pause:
    post:
      tags: [Jobs]
      operationId: pauseJob
      summary: Pause a job
      description: ACTIVE → PAUSED. The listing is hidden until resumed.
      parameters: [{ $ref: "#/components/parameters/jobId" }]
      responses:
        "200":
          description: The job, now PAUSED.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/InvalidTransition" }

  /jobs/{id}/resume:
    post:
      tags: [Jobs]
      operationId: resumeJob
      summary: Resume a paused job
      description: PAUSED → ACTIVE.
      parameters: [{ $ref: "#/components/parameters/jobId" }]
      responses:
        "200":
          description: The job, ACTIVE again.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/InvalidTransition" }

  /jobs/{id}/close:
    post:
      tags: [Jobs]
      operationId: closeJob
      summary: Close a job
      description: ACTIVE/PAUSED → CLOSED. Terminal. Closed jobs can't reopen or be edited.
      parameters: [{ $ref: "#/components/parameters/jobId" }]
      responses:
        "200":
          description: The job, now CLOSED.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/InvalidTransition" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: Partner key (`sk_ext_…`) bound to your company.

  parameters:
    jobId:
      name: id
      in: path
      required: true
      schema: { type: integer }
      description: The job's stable cross-service id (`svcAtsId`).

  responses:
    Unauthorized:
      description: Missing, unknown, or revoked partner key.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: INVALID_SERVICE_KEY, message: Invalid service key. }
    NotFound:
      description: No such job for this key (including other companies' jobs).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error: { code: NOT_FOUND, message: Job not found. }
    ValidationFailed:
      description: Bad input or incomplete listing; `fields` names each problem.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: VALIDATION_FAILED
              message: Validation failed.
              fields: { salaryMin: Add a minimum salary. }
    AgreementOutdated:
      description: Terms must be accepted in the dashboard before publishing.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: AGREEMENT_OUTDATED
              message: Your organization must accept the current terms in the Sorce dashboard before publishing via the API.
    PaymentMethodRequired:
      description: A card on file is required before publishing.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: PAYMENT_METHOD_REQUIRED
              message: Add a payment method before publishing.
    InvalidTransition:
      description: The status change isn't legal from the job's current state.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example:
            error:
              code: INVALID_TRANSITION
              message: Only active jobs can be paused.

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
            fields:
              type: object
              additionalProperties: { type: string }
          required: [code, message]

    LocationSuggestion:
      type: object
      properties:
        locationId: { type: integer, example: 17024 }
        displayName: { type: string, example: "San Francisco, California, United States" }
        city: { type: string, example: San Francisco }
        stateProvince: { type: string, example: California }
        country: { type: string, example: United States }

    LocationInput:
      type: object
      description: Always sourced from /locations/search.
      required: [locationId, city, stateProvince, country]
      properties:
        locationId: { type: integer, example: 17024 }
        city: { type: string, example: San Francisco }
        stateProvince: { type: string, example: California }
        country: { type: string, example: United States }

    QualificationInput:
      type: object
      required: [text, kind, order]
      properties:
        text: { type: string, example: 5+ years of product design experience }
        kind: { type: string, enum: [REQUIRED, DESIRED] }
        order: { type: integer, example: 0 }

    QuestionInput:
      type: object
      description: >
        A custom screening question. Applicant basics (name, email, phone,
        location, resume) are collected automatically on every application and
        must not be sent as questions.
      required: [prompt, answerFormat, order]
      properties:
        prompt: { type: string, example: Why do you want to work here? }
        answerFormat:
          type: string
          enum: [TEXT, TEXTAREA, SINGLE_SELECT, MULTI_SELECT, NUMBER, FILE, DATE, SINGLE_CHECKBOX]
          description: >
            SINGLE_SELECT (pick exactly one option), MULTI_SELECT (pick one
            or more options), SINGLE_CHECKBOX (a single boolean toggle), and
            the freeform/typed formats (TEXT, TEXTAREA, NUMBER, FILE, DATE).
        options:
          type: array
          items: { type: string }
          description: For SINGLE_SELECT and MULTI_SELECT.
        order: { type: integer }

    BudgetInput:
      type: object
      required: [window, budgetCents]
      properties:
        window: { type: string, enum: [DAILY, WEEKLY, MONTHLY, LIFETIME] }
        budgetCents: { type: integer, minimum: 0, example: 50000 }
        notes: { type: [string, "null"] }

    JobInput:
      type: object
      description: >
        All fields optional; arrays use replace semantics (the set you send
        becomes the entire new set).
      properties:
        title: { type: string, example: Senior Product Designer }
        description: { type: string }
        remoteOption: { type: [string, "null"], enum: [REMOTE, HYBRID, IN_PERSON, null] }
        remoteCountry:
          type: [string, "null"]
          description: Where remote employees may work (REMOTE jobs).
        jobType: { type: [string, "null"], enum: [FULL_TIME, PART_TIME, CONTRACT, INTERNSHIP, null] }
        jobLevel: { type: [string, "null"], enum: [ENTRY, MID, SENIOR, null] }
        locations:
          type: array
          items: { $ref: "#/components/schemas/LocationInput" }
        salaryMin: { type: [integer, "null"], description: Whole dollars, example 150000. }
        salaryMax: { type: [integer, "null"] }
        salaryPeriod: { type: [string, "null"], enum: [ANNUAL, HOURLY, null] }
        bidPerQualifiedApplicantCents:
          type: [integer, "null"]
          description: What you pay per qualified applicant, in cents.
        opportunityId:
          type: [string, "null"]
          description: >
            Your id for this opening on your source ATS. Optional; used by
            post-application flows to route submitted applications back.
        atsJobUrl:
          type: [string, "null"]
          format: uri
          description: >
            Canonical job URL on your source ATS. Optional; used by
            post-application flows alongside opportunityId.
        qualifications:
          type: array
          items: { $ref: "#/components/schemas/QualificationInput" }
        questions:
          type: array
          description: >
            Custom screening questions only. The applicant basics - first and
            last name, email, phone, location, and resume - are collected
            automatically on every application; never send them as questions.
          items: { $ref: "#/components/schemas/QuestionInput" }
        budgets:
          type: array
          items: { $ref: "#/components/schemas/BudgetInput" }

    Job:
      type: object
      description: The full job, as the dashboard sees it.
      properties:
        id:
          type: integer
          description: Stable cross-service job id (`svcAtsId`).
        title: { type: string }
        status: { type: string, enum: [DRAFT, ACTIVE, PAUSED, CLOSED, EXPIRED] }
        description: { type: string }
        remoteOption: { type: [string, "null"], enum: [REMOTE, HYBRID, IN_PERSON, null] }
        remoteCountry: { type: [string, "null"] }
        jobType: { type: [string, "null"], enum: [FULL_TIME, PART_TIME, CONTRACT, INTERNSHIP, null] }
        jobLevel: { type: [string, "null"], enum: [ENTRY, MID, SENIOR, null] }
        salaryMin: { type: [integer, "null"] }
        salaryMax: { type: [integer, "null"] }
        salaryPeriod: { type: [string, "null"], enum: [ANNUAL, HOURLY, null] }
        bidPerQualifiedApplicantCents: { type: [integer, "null"] }
        opportunityId:
          type: [string, "null"]
          description: Partner id for this opening on the source ATS.
        atsJobUrl:
          type: [string, "null"]
          format: uri
          description: Canonical job URL on the source ATS.
        companyId: { type: string }
        locations:
          type: array
          items: { $ref: "#/components/schemas/LocationSuggestion" }
        qualifications:
          type: array
          items:
            allOf:
              - { $ref: "#/components/schemas/QualificationInput" }
              - type: object
                properties:
                  id: { type: string }
        questions:
          type: array
          items:
            allOf:
              - { $ref: "#/components/schemas/QuestionInput" }
              - type: object
                properties:
                  id: { type: string }
        budgets:
          type: array
          items: { $ref: "#/components/schemas/BudgetInput" }
        applicationCounts:
          type: object
          properties:
            pending: { type: integer }
            accepted: { type: integer }
            rejected: { type: integer }
        isFrozen:
          type: boolean
          description: True once the job has applications. Qualifications/questions locked.
        newApplicantCount: { type: integer }
        publishedAt: { type: [string, "null"], format: date-time }
        expiresAt: { type: [string, "null"], format: date-time }
        closedAt: { type: [string, "null"], format: date-time }
