openapi: 3.1.0
info:
  title: Career Unified Partner API
  version: 1.0.0
  description: Versioned API for opportunity discovery and approved recruitment partners.
  contact:
    name: Career Unified Support
    url: https://careerunified.com/contact-us
servers:
  - url: https://careerunified.com/api/v1
security: []
tags:
  - name: Discovery
  - name: Partner jobs
  - name: Applications
  - name: Webhooks
  - name: Administration
  - name: Partner management
paths:
  /status:
    get:
      tags: [Discovery]
      summary: Check API availability
      responses:
        '200':
          description: API is available
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SuccessResponse'
  /jobs:
    get:
      tags: [Discovery]
      summary: List active jobs
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: q
          in: query
          schema: {type: string, maxLength: 200}
        - name: category
          in: query
          schema: {type: string}
        - name: source
          in: query
          schema:
            type: string
            enum: [career_unified_editorial, career_unified_recruiter]
        - name: applicationMethod
          in: query
          schema:
            type: string
            enum: [direct, external, email]
      responses:
        '200':
          description: Active jobs
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: {$ref: '#/components/schemas/Job'}
                  meta: {$ref: '#/components/schemas/PageMeta'}
        '429': {$ref: '#/components/responses/Error'}
    post:
      tags: [Partner jobs]
      summary: Publish a recruiter job
      security: [{ApiKey: []}]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/JobWrite'}
      responses:
        '201':
          description: Job published
          content:
            application/json:
              schema: {$ref: '#/components/schemas/JobResponse'}
        '400': {$ref: '#/components/responses/Error'}
        '401': {$ref: '#/components/responses/Error'}
        '402': {$ref: '#/components/responses/Error'}
        '403': {$ref: '#/components/responses/Error'}
  /jobs/{jobId}:
    parameters:
      - $ref: '#/components/parameters/JobId'
    get:
      tags: [Discovery]
      summary: Get a job by slug or identifier
      responses:
        '200':
          description: Job
          content:
            application/json:
              schema: {$ref: '#/components/schemas/JobResponse'}
        '404': {$ref: '#/components/responses/Error'}
    patch:
      tags: [Partner jobs]
      summary: Update an owned recruiter job
      security: [{ApiKey: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/JobWrite'}
      responses:
        '200':
          description: Job updated
          content:
            application/json:
              schema: {$ref: '#/components/schemas/JobResponse'}
        '400': {$ref: '#/components/responses/Error'}
        '404': {$ref: '#/components/responses/Error'}
  /jobs/{jobId}/close:
    post:
      tags: [Partner jobs]
      summary: Close an owned recruiter job
      security: [{ApiKey: []}]
      parameters:
        - $ref: '#/components/parameters/JobId'
      responses:
        '200':
          description: Job closed
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /jobs/{jobId}/applications/test:
    post:
      tags: [Applications]
      summary: Create a synthetic sandbox application
      description: Available only to cu_test_ clients. The application remains isolated from live data.
      security: [{ApiKey: []}]
      parameters:
        - $ref: '#/components/parameters/JobId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                candidate:
                  type: object
                  properties:
                    name: {type: string}
                    email: {type: string, format: email}
      responses:
        '201':
          description: Synthetic application created
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '403': {$ref: '#/components/responses/Error'}
        '404': {$ref: '#/components/responses/Error'}
  /jobs/{jobId}/applications:
    get:
      tags: [Applications]
      summary: List applications for an owned job
      security: [{ApiKey: []}]
      parameters:
        - $ref: '#/components/parameters/JobId'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: status
          in: query
          schema: {type: string}
      responses:
        '200':
          description: Applications
          content:
            application/json:
              schema:
                type: object
                required: [data, meta]
                properties:
                  data:
                    type: array
                    items: {$ref: '#/components/schemas/Application'}
                  meta: {$ref: '#/components/schemas/PageMeta'}
        '403': {$ref: '#/components/responses/Error'}
        '404': {$ref: '#/components/responses/Error'}
  /applications/{applicationId}:
    parameters:
      - name: applicationId
        in: path
        required: true
        schema: {type: string}
    get:
      tags: [Applications]
      summary: Get an owned application
      security: [{ApiKey: []}]
      responses:
        '200':
          description: Application
          content:
            application/json:
              schema: {$ref: '#/components/schemas/ApplicationResponse'}
        '404': {$ref: '#/components/responses/Error'}
    patch:
      tags: [Applications]
      summary: Change an application's recruitment stage
      security: [{ApiKey: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [viewed, shortlisted, interview, offer, hired, unsuccessful]
      responses:
        '200':
          description: Stage changed
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '400': {$ref: '#/components/responses/Error'}
        '404': {$ref: '#/components/responses/Error'}
  /bursaries:
    get:
      tags: [Discovery]
      summary: List active bursaries
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
        - name: q
          in: query
          schema: {type: string, maxLength: 200}
        - name: faculty
          in: query
          schema: {type: string}
      responses:
        '200':
          description: Active bursaries
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: {$ref: '#/components/schemas/Bursary'}
                  meta: {$ref: '#/components/schemas/PageMeta'}
  /bursaries/{bursaryId}:
    get:
      tags: [Discovery]
      summary: Get a bursary by slug or identifier
      parameters:
        - name: bursaryId
          in: path
          required: true
          schema: {type: string}
      responses:
        '200':
          description: Bursary
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: {$ref: '#/components/schemas/Bursary'}
        '404': {$ref: '#/components/responses/Error'}
  /webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook endpoints
      security: [{ApiKey: []}]
      responses:
        '200':
          description: Webhook endpoints
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
    post:
      tags: [Webhooks]
      summary: Create a signed webhook endpoint
      security: [{ApiKey: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/WebhookWrite'}
      responses:
        '201':
          description: Webhook created; signing secret is returned once
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '400': {$ref: '#/components/responses/Error'}
  /webhooks/{webhookId}:
    delete:
      tags: [Webhooks]
      summary: Disable a webhook endpoint
      security: [{ApiKey: []}]
      parameters:
        - name: webhookId
          in: path
          required: true
          schema: {type: string}
      responses:
        '204': {description: Webhook disabled}
        '404': {$ref: '#/components/responses/Error'}
  /webhooks/{webhookId}/deliveries:
    get:
      tags: [Webhooks]
      summary: List recent webhook delivery attempts
      security: [{ApiKey: []}]
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: Delivery history
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /webhooks/{webhookId}/rotate-secret:
    post:
      tags: [Webhooks]
      summary: Rotate a webhook signing secret
      security: [{ApiKey: []}]
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: New signing secret returned once
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /webhooks/{webhookId}/test:
    post:
      tags: [Webhooks]
      summary: Queue a signed test webhook
      security: [{ApiKey: []}]
      parameters:
        - $ref: '#/components/parameters/WebhookId'
      responses:
        '200':
          description: Test event queued
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /webhooks/events/{eventId}/replay:
    post:
      tags: [Webhooks]
      summary: Replay an owned webhook event
      security: [{ApiKey: []}]
      parameters:
        - name: eventId
          in: path
          required: true
          schema: {type: string}
      responses:
        '202':
          description: Event replay queued
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /admin/clients:
    get:
      tags: [Administration]
      summary: List API clients
      security: [{FirebaseAdminToken: []}]
      responses:
        '200':
          description: API clients without key hashes or secrets
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
    post:
      tags: [Administration]
      summary: Create an API client and return its key once
      security: [{FirebaseAdminToken: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: '#/components/schemas/ApiClientWrite'}
      responses:
        '201':
          description: API client created
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '403': {$ref: '#/components/responses/Error'}
  /admin/clients/{clientId}:
    get:
      tags: [Administration]
      summary: Retrieve an API client with usage and alerts
      security: [{FirebaseAdminToken: []}]
      parameters:
        - name: clientId
          in: path
          required: true
          schema: {type: string}
        - name: month
          in: query
          schema: {type: string, pattern: '^\d{4}-\d{2}$'}
      responses:
        '200':
          description: API client, usage summary, and alerts
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
    delete:
      tags: [Administration]
      summary: Revoke an API client
      security: [{FirebaseAdminToken: []}]
      parameters:
        - name: clientId
          in: path
          required: true
          schema: {type: string}
      responses:
        '204': {description: API client revoked}
        '404': {$ref: '#/components/responses/Error'}
  /admin/clients/{clientId}/rotate:
    post:
      tags: [Administration]
      summary: Rotate an API client secret and return the new key once
      security: [{FirebaseAdminToken: []}]
      parameters:
        - name: clientId
          in: path
          required: true
          schema: {type: string}
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                graceHours: {type: integer, minimum: 1, maximum: 168, default: 24}
      responses:
        '200':
          description: API key rotated
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /admin/clients/{clientId}/usage:
    get:
      tags: [Administration]
      summary: Get daily and monthly API usage
      security: [{FirebaseAdminToken: []}]
      parameters:
        - name: clientId
          in: path
          required: true
          schema: {type: string}
        - name: month
          in: query
          schema: {type: string, pattern: '^\d{4}-\d{2}$'}
      responses:
        '200':
          description: API usage summary
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /admin/clients/{clientId}/usage.csv:
    get:
      tags: [Administration]
      summary: Download daily API usage as CSV
      security: [{FirebaseAdminToken: []}]
      parameters:
        - name: clientId
          in: path
          required: true
          schema: {type: string}
        - name: month
          in: query
          schema: {type: string, pattern: '^\d{4}-\d{2}$'}
      responses:
        '200':
          description: CSV usage report
          content:
            text/csv:
              schema: {type: string}
        '404': {$ref: '#/components/responses/Error'}
  /admin/clients/{clientId}/finalize-rotation:
    post:
      tags: [Administration]
      summary: Stop accepting the previous API key immediately
      security: [{FirebaseAdminToken: []}]
      parameters:
        - $ref: '#/components/parameters/ClientId'
      responses:
        '200':
          description: Rotation finalised
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /admin/clients/{clientId}/alerts/{alertId}/acknowledge:
    post:
      tags: [Administration]
      summary: Acknowledge an API client alert
      security: [{FirebaseAdminToken: []}]
      parameters:
        - $ref: '#/components/parameters/ClientId'
        - $ref: '#/components/parameters/AlertId'
      responses:
        '200':
          description: Alert acknowledged
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /partner/clients:
    get:
      tags: [Partner management]
      summary: List API clients owned by the signed-in recruiter
      security: [{FirebasePartnerToken: []}]
      responses:
        '200':
          description: Owned API clients
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '403': {$ref: '#/components/responses/Error'}
  /partner/clients/{clientId}:
    get:
      tags: [Partner management]
      summary: Retrieve an owned client with usage, alerts, and invoice statements
      security: [{FirebasePartnerToken: []}]
      parameters:
        - $ref: '#/components/parameters/ClientId'
        - name: month
          in: query
          schema: {type: string, pattern: '^\d{4}-\d{2}$'}
      responses:
        '200':
          description: Owned API client details
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /partner/clients/{clientId}/rotate:
    post:
      tags: [Partner management]
      summary: Rotate an owned API key with a grace period
      security: [{FirebasePartnerToken: []}]
      parameters:
        - $ref: '#/components/parameters/ClientId'
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                graceHours: {type: integer, minimum: 1, maximum: 168, default: 24}
      responses:
        '200':
          description: New API key returned once
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /partner/clients/{clientId}/finalize-rotation:
    post:
      tags: [Partner management]
      summary: Stop accepting the previous owned API key
      security: [{FirebasePartnerToken: []}]
      parameters:
        - $ref: '#/components/parameters/ClientId'
      responses:
        '200':
          description: Rotation finalised
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
  /partner/clients/{clientId}/usage.csv:
    get:
      tags: [Partner management]
      summary: Download owned API usage as CSV
      security: [{FirebasePartnerToken: []}]
      parameters:
        - $ref: '#/components/parameters/ClientId'
        - name: month
          in: query
          schema: {type: string, pattern: '^\d{4}-\d{2}$'}
      responses:
        '200':
          description: CSV usage report
          content:
            text/csv:
              schema: {type: string}
  /partner/clients/{clientId}/invoices/{invoiceId}.csv:
    get:
      tags: [Partner management]
      summary: Download an owned API invoice statement
      security: [{FirebasePartnerToken: []}]
      parameters:
        - $ref: '#/components/parameters/ClientId'
        - name: invoiceId
          in: path
          required: true
          schema: {type: string}
      responses:
        '200':
          description: CSV invoice statement
          content:
            text/csv:
              schema: {type: string}
        '404': {$ref: '#/components/responses/Error'}
  /partner/clients/{clientId}/alerts/{alertId}/acknowledge:
    post:
      tags: [Partner management]
      summary: Acknowledge an owned API alert
      security: [{FirebasePartnerToken: []}]
      parameters:
        - $ref: '#/components/parameters/ClientId'
        - $ref: '#/components/parameters/AlertId'
      responses:
        '200':
          description: Alert acknowledged
          content:
            application/json:
              schema: {$ref: '#/components/schemas/SuccessResponse'}
        '404': {$ref: '#/components/responses/Error'}
components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-API-Key
    FirebaseAdminToken:
      type: http
      scheme: bearer
      bearerFormat: Firebase ID token with admin claim
    FirebasePartnerToken:
      type: http
      scheme: bearer
      bearerFormat: Firebase ID token for an approved recruiter API partner
  parameters:
    Limit:
      name: limit
      in: query
      schema: {type: integer, minimum: 1, maximum: 100, default: 25}
    Cursor:
      name: cursor
      in: query
      schema: {type: string}
    JobId:
      name: jobId
      in: path
      required: true
      schema: {type: string}
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: A unique key that makes job creation safe to retry.
      schema:
        type: string
        minLength: 8
        maxLength: 180
        pattern: '^[A-Za-z0-9._:-]+$'
    WebhookId:
      name: webhookId
      in: path
      required: true
      schema: {type: string}
    ClientId:
      name: clientId
      in: path
      required: true
      schema: {type: string}
    AlertId:
      name: alertId
      in: path
      required: true
      schema: {type: string}
  responses:
    Error:
      description: Request error
      content:
        application/json:
          schema: {$ref: '#/components/schemas/ErrorResponse'}
  schemas:
    SuccessResponse:
      type: object
      required: [data]
      properties:
        data: {}
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, requestId]
          properties:
            code: {type: string}
            message: {type: string}
            requestId: {type: string, format: uuid}
            details: {}
    PageMeta:
      type: object
      required: [total, limit, hasMore]
      properties:
        total: {type: integer, minimum: 0}
        limit: {type: integer, minimum: 1, maximum: 100}
        hasMore: {type: boolean}
        nextCursor: {type: [string, 'null']}
    Job:
      type: object
      required: [id, source, slug, title, organization, dates, application, links]
      properties:
        id: {type: string}
        source: {type: string}
        slug: {type: string}
        title: {type: string}
        description: {type: [string, 'null']}
        category: {type: [string, 'null']}
        employmentType: {type: [string, 'null']}
        workPreference: {type: [string, 'null']}
        experienceLevel: {type: [string, 'null']}
        location:
          type: object
          properties:
            display: {type: string}
            city: {type: [string, 'null']}
            country: {type: [string, 'null']}
        salary: {type: [string, 'null']}
        organization:
          type: object
          properties:
            id: {type: [string, 'null']}
            name: {type: string}
            logoUrl: {type: [string, 'null']}
            website: {type: [string, 'null']}
            socialLinks: {type: object, additionalProperties: {type: string}}
        dates:
          type: object
          properties:
            posted: {type: [string, 'null']}
            closing: {type: [string, 'null']}
            closingText: {type: [string, 'null']}
            updatedAt: {type: [string, 'null']}
        application:
          type: object
          properties:
            method: {type: string, enum: [direct, external, email]}
            url: {type: [string, 'null']}
            screeningQuestionCount: {type: integer}
        links:
          type: object
          properties:
            self: {type: string, format: uri}
            web: {type: string, format: uri}
    JobResponse:
      type: object
      required: [data]
      properties:
        data: {$ref: '#/components/schemas/Job'}
    JobWrite:
      type: object
      required: [title, category, employmentType, description, city]
      properties:
        title: {type: string, maxLength: 180}
        category: {type: string, maxLength: 120}
        employmentType: {type: string, maxLength: 120}
        experienceLevel: {type: string, maxLength: 120}
        description: {type: string, maxLength: 30000}
        overview: {type: string, maxLength: 20000}
        responsibilities: {type: string, maxLength: 20000}
        requirements: {type: string, maxLength: 20000}
        city: {type: string, maxLength: 120}
        country: {type: string, default: South Africa, maxLength: 120}
        salary: {type: string, maxLength: 160}
        closingDate: {type: string, format: date}
        closingText: {type: string, maxLength: 120}
        applicationMethod: {type: string, enum: [direct, external, email], default: direct}
        applyLink: {type: string, format: uri, pattern: '^https://'}
        screeningQuestions:
          type: array
          maxItems: 8
          items:
            type: object
            required: [label, type]
            properties:
              id: {type: string}
              label: {type: string, maxLength: 240}
              type: {type: string, enum: [yes_no, number, single_select, multi_select, short_text]}
              required: {type: boolean, default: true}
              options: {type: array, maxItems: 20, items: {type: string}}
    Bursary:
      type: object
      required: [id, slug, name, provider, faculties, links]
      properties:
        id: {type: string}
        source: {type: string}
        slug: {type: string}
        name: {type: string}
        provider: {type: string}
        description: {type: [string, 'null']}
        faculties: {type: array, items: {type: string}}
        closingDate: {type: [string, 'null'], format: date}
        providerLogoUrl: {type: [string, 'null']}
        applicationUrl: {type: [string, 'null']}
        updatedAt: {type: [string, 'null']}
        links:
          type: object
          properties:
            self: {type: string, format: uri}
            web: {type: string, format: uri}
    Application:
      type: object
      required: [id, jobId, status, candidate, job, links]
      properties:
        id: {type: string}
        jobId: {type: string}
        status: {type: string}
        screeningResult: {type: [string, 'null']}
        submittedAt: {type: [string, 'null'], format: date-time}
        updatedAt: {type: [string, 'null'], format: date-time}
        candidate:
          type: object
          properties:
            name: {type: string}
            email: {type: string, format: email}
            phone: {type: [string, 'null']}
            location: {type: [string, 'null']}
            qualification: {type: [string, 'null']}
        job: {type: object}
        links: {type: object}
    ApplicationResponse:
      type: object
      required: [data]
      properties:
        data:
          allOf:
            - {$ref: '#/components/schemas/Application'}
            - type: object
              properties:
                answers: {type: array, items: {type: object}}
                coverLetter: {type: [string, 'null']}
                cv: {type: object}
    WebhookWrite:
      type: object
      required: [url, events]
      properties:
        url: {type: string, format: uri, pattern: '^https://'}
        description: {type: string, maxLength: 240}
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
            enum:
              [
                job.published,
                job.updated,
                job.closed,
                application.received,
                application.stage_changed,
              ]
    ApiClientWrite:
      type: object
      required: [name, recruiterId, scopes]
      properties:
        name: {type: string, maxLength: 160}
        recruiterId: {type: string}
        environment: {type: string, enum: [live, test], default: live}
        apiPlan: {type: string, enum: [pilot, starter, growth, enterprise], default: pilot}
        scopes:
          type: array
          minItems: 1
          uniqueItems: true
          items:
            type: string
            enum: [jobs:read, jobs:write, applications:read, applications:write, webhooks:manage]
        rateLimitPerMinute: {type: integer, minimum: 30, maximum: 5000, default: 600}
        monthlyQuota: {type: integer, minimum: 0, maximum: 5000000}
        billingMode: {type: string, enum: [included, usage, manual], default: included}
        overageRateCents: {type: integer, minimum: 0, maximum: 100000, default: 0}
        alertEmails:
          type: array
          maxItems: 10
          items: {type: string, format: email}
        allowedOrigins:
          type: array
          maxItems: 20
          items: {type: string, format: uri, pattern: '^https://'}
