openapi: 3.1.0
info:
  title: H1R3 API (DRAFT, pre-launch, not live)
  version: 0.9.0-draft
  description: H1R3 bot-first job board API. Pricing (AUD incl GST) seekers A$24/yr bot, A$48/yr Front Desk; employers A$48/yr bot, A$96/yr Front Desk + A$2/A$4 per job. The annual fee INCLUDES verification (one Stripe Identity document+selfie check on activation, unlimited cert checks, referee links and the three scores). Three verification scores (identity, skills_certs, references; 0-100 with level and breakdown) are documented in /llms.txt. Verification principle - verify and referee ONCE; the verified passport belongs to the seeker and is reused for every employer they apply to (subject to their visibility setting). Re-verify only when a cert expires, a role is added, or a reference is older than 24 months. All endpoints are subject to change. Prices AUD incl. GST.
servers:
  - url: https://h1r3.h1r3.workers.dev   # live (free workers.dev)
security:
  - bearer: []
components:
  securitySchemes:
    bearer: {type: http, scheme: bearer}
    admin: {type: apiKey, in: header, name: x-admin-key}
  schemas:
    Role: {type: string, enum: [seeker, employer]}
    Job:
      type: object
      required: [title, location, work_type, description]
      properties:
        id: {type: string}
        title: {type: string}
        location: {type: string, description: Suburb or postcode}
        remote: {type: boolean}
        work_type: {type: string, enum: [full_time, part_time, casual, contract]}
        pay_min: {type: number}
        pay_max: {type: number}
        pay_unit: {type: string, enum: [hour, year]}
        skills: {type: array, items: {type: string}}
        description: {type: string}
    Error: {type: object, properties: {error: {type: string}}}
paths:
  /api/waitlist:
    post:
      operationId: waitlist_join
      summary: Join the pre-launch waitlist (no auth)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role, email, has_bot, postcode]
              properties:
                role: {$ref: '#/components/schemas/Role'}
                email: {type: string, format: email}
                has_bot: {type: boolean}
                postcode: {type: string, pattern: '^\d{4}$'}
                callback_url: {type: string, format: uri, description: Optional webhook for bots without inboxes}
      responses:
        '201': {description: Added}
        '422': {description: Invalid, content: {application/json: {schema: {$ref: '#/components/schemas/Error'}}}}
  /api/v1/register:
    post:
      operationId: register
      summary: Create account; returns Stripe Checkout URL for annual fee (seekers A$24 bot / A$48 Front Desk; employers A$48 / A$96)
      security: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [role, email]
              properties:
                role: {$ref: '#/components/schemas/Role'}
                email: {type: string, format: email}
                channel: {type: string, enum: [bot, front_desk], default: bot}
                abn: {type: string, description: "Employers: valid ABN auto-claims a Founding Employer slot if available (first 100; one per ABN)"}
      responses:
        '201':
          description: Pending payment
          content: {application/json: {schema: {type: object, properties: {account_id: {type: string}, checkout_url: {type: string}}}}}
  /api/v1/verify:
    post:
      operationId: verify
      summary: Submit verification (employer ABN, licence number, or start ID check)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                type: {type: string, enum: [abn, licence, identity]}
                abn: {type: string, pattern: '^\d{11}$'}
                licence: {type: object, properties: {register: {type: string}, number: {type: string}}}
      responses:
        '200': {description: Verification result or pending}
  /api/v1/credits:
    post:
      operationId: buy_credits
      summary: Buy prepaid job credits (A$2/job bot, A$4/job Front Desk; min pack 10)
      requestBody:
        content:
          application/json:
            schema: {type: object, required: [quantity], properties: {quantity: {type: integer, minimum: 10}}}
      responses:
        '200': {description: Checkout URL, content: {application/json: {schema: {type: object, properties: {checkout_url: {type: string}}}}}}
  /api/v1/jobs:
    post:
      operationId: post_job
      summary: Post a job (employer, ABN-verified; 1 credit, live 30 days). Response includes `warnings` (e.g. pay below National Minimum Wage A$26.44/hr from 1 Jul 2026) and auto-moderation `flags`; banned phrases put the job in pending_review.
      requestBody:
        content: {application/json: {schema: {$ref: '#/components/schemas/Job'}}}
      responses:
        '201': {description: Created, content: {application/json: {schema: {$ref: '#/components/schemas/Job'}}}}
        '402': {description: No credits}
  /api/v1/jobs/search:
    post:
      operationId: search_jobs
      summary: Search jobs
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                near: {type: string}
                radius_km: {type: number}
                skills: {type: array, items: {type: string}}
                work_type: {type: string}
                pay_min: {type: number}
      responses:
        '200': {description: Results, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Job'}}}}}
  /api/v1/jobs/{job_id}/apply:
    post:
      operationId: apply
      summary: Apply to a job (verified seeker; monthly free cap applies)
      parameters:
        - {name: job_id, in: path, required: true, schema: {type: string}}
      requestBody:
        content:
          application/json:
            schema: {type: object, properties: {cover_note: {type: string, maxLength: 2000}, screening_answers: {type: object}}}
      responses:
        '201': {description: Application submitted}
        '429': {description: Application cap reached}
  /api/v1/jobs/{job_id}/applications:
    get:
      operationId: list_applications
      summary: List applicants for a job you own (employer). Alias /api/jobs/{job_id}/applications. Includes profile + verified badges.
      parameters:
        - {name: job_id, in: path, required: true, schema: {type: string}}
      responses:
        '200': {description: Applicants}
        '404': {description: Not found or not your job}
  /api/v1/applications/{application_id}:
    patch:
      operationId: update_application
      summary: Shortlist or reject (employer). Emails the seeker (dev outbox).
      parameters:
        - {name: application_id, in: path, required: true, schema: {type: string}}
      requestBody:
        content: {application/json: {schema: {type: object, required: [status], properties: {status: {type: string, enum: [shortlisted, rejected]}}}}}
      responses:
        '200': {description: Updated}
  /api/v1/profile:
    get:
      operationId: get_profile
      summary: Get own seeker profile with verified badges
      responses: {'200': {description: Profile}, '404': {description: No profile}}
    put:
      operationId: update_profile
      summary: Create/update seeker profile (partial updates allowed)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                display_name: {type: string}
                skills: {type: array, items: {type: string}, maxItems: 30}
                suburb: {type: string}
                postcode: {type: string, pattern: '^\d{4}$'}
                availability: {type: string}
                work_rights: {type: boolean, description: Has the right to work in Australia (self-declared)}
      responses: {'200': {description: Profile}}
  /api/v1/seekers/search:
    post:
      operationId: search_seekers
      summary: Search seeker profiles (ABN-verified employers)
      requestBody:
        content: {application/json: {schema: {type: object, properties: {postcode: {type: string}, skills: {type: array, items: {type: string}}, verified_skill: {type: string}, work_rights: {type: boolean}}}}}
      responses: {'200': {description: Seekers with badges}}
  /api/v1/skills/tests:
    get:
      operationId: list_skill_tests
      security: []
      summary: List skills tests (barista_hospitality, retail_customer_service, rsa_basics, bookkeeping_xero, excel_basics). rsa_basics does NOT replace official RSA certification.
      responses: {'200': {description: Tests}}
  /api/v1/skills/tests/{skill}/start:
    post:
      operationId: start_test
      summary: Start a timed attempt (seekers; 8 random questions, shuffled options, 10 min, one attempt per 7 days)
      parameters:
        - {name: skill, in: path, required: true, schema: {type: string}}
      responses: {'201': {description: Questions}, '429': {description: Retake too soon}}
  /api/v1/skills/attempts/{attempt_id}/submit:
    post:
      operationId: submit_test
      summary: Submit 0-based option indexes in order; pass mark 75% issues a verified badge (valid 365 days)
      parameters:
        - {name: attempt_id, in: path, required: true, schema: {type: string}}
      requestBody:
        content: {application/json: {schema: {type: object, required: [answers], properties: {answers: {type: array, items: {type: integer}}}}}}
      responses: {'200': {description: Score/badge}, '408': {description: Time limit exceeded}}
  /api/admin/jobs:
    get:
      operationId: admin_list_jobs
      summary: "Admin: list jobs (?flagged=1). Header x-admin-key."
      security: [{admin: []}]
      responses: {'200': {description: Jobs}}
  /api/admin/jobs/{job_id}/{action}:
    post:
      operationId: admin_job_action
      summary: "Admin: action = flag | remove | approve"
      security: [{admin: []}]
      parameters:
        - {name: job_id, in: path, required: true, schema: {type: string}}
        - {name: action, in: path, required: true, schema: {type: string, enum: [flag, remove, approve]}}
      responses: {'200': {description: OK}}
  /api/admin/accounts/{account_id}/suspend:
    post:
      operationId: admin_suspend
      summary: "Admin: suspend account (removes its live jobs)"
      security: [{admin: []}]
      parameters:
        - {name: account_id, in: path, required: true, schema: {type: string}}
      responses: {'200': {description: Suspended}}
  /api/frontdesk:
    post:
      operationId: front_desk
      security: []
      summary: Rule-based Front Desk guide (LLM planned)
      requestBody:
        content: {application/json: {schema: {type: object, properties: {message: {type: string}}}}}
      responses: {'200': {description: Reply}}
  # ---------------- Verification engine (portable "verified passport": verify ONCE, reuse for every employer) ----------------
  /api/v1/privacy:
    put:
      operationId: set_privacy
      summary: "Consent (required before certs/roles) + visibility: private | applied (default, only employers you applied to) | searchable"
      requestBody: {content: {application/json: {schema: {type: object, properties: {consent: {type: boolean}, visibility: {type: string, enum: [private, applied, searchable]}}}}}}
      responses: {'200': {description: Privacy settings}}
  /api/v1/certs/types:
    get: {operationId: list_cert_types, security: [], summary: "Cert/licence types and verification method (register lookup link, issuer confirmation, admin)", responses: {'200': {description: Types}}}
  /api/v1/certs:
    get: {operationId: list_certs, summary: Own certs (auto-expired by date), responses: {'200': {description: Certs}}}
    post:
      operationId: add_cert
      summary: "Add cert metadata + optional document SHA-256 (no file storage). Status: unverified | pending | verified | rejected | expired"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties: {type: {type: string}, issuer: {type: string}, number: {type: string}, state: {type: string}, issue_date: {type: string, format: date}, expiry_date: {type: string, format: date}, issuer_email: {type: string}, doc_sha256: {type: string, pattern: '^[a-f0-9]{64}$'}}
      responses: {'201': {description: Created (includes register_lookup link where applicable)}, '428': {description: Consent required}}
  /api/v1/certs/{cert_id}/issuer-link:
    post:
      operationId: request_issuer_confirmation
      summary: Single-use 30-day link for the issuer to confirm yes/no (no email sent; share it yourself)
      parameters: [{name: cert_id, in: path, required: true, schema: {type: string}}]
      responses: {'201': {description: Link}}
  /api/verify/cert/{token}:
    get: {operationId: cert_confirmation_info, security: [], parameters: [{name: token, in: path, required: true, schema: {type: string}}], responses: {'200': {description: Cert details}, '410': {description: Used/expired}}}
    post:
      operationId: cert_confirmation_submit
      security: []
      parameters: [{name: token, in: path, required: true, schema: {type: string}}]
      requestBody: {content: {application/json: {schema: {type: object, required: [confirmed], properties: {confirmed: {type: boolean}, comment: {type: string}}}}}}
      responses: {'200': {description: Recorded}}
  /api/v1/tasks:
    get: {operationId: list_task_taxonomy, security: [], summary: Task taxonomy codes per job category + corroborating certs/badges, responses: {'200': {description: Taxonomy}}}
  /api/v1/roles:
    post:
      operationId: add_role
      summary: "Employment history role with claimed tasks. Current roles or roles ended within 24 months are reference-eligible; older roles need no referee."
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [employer_name, title, start_date]
              properties: {employer_name: {type: string}, employer_abn: {type: string}, employer_domain: {type: string}, title: {type: string}, start_date: {type: string, format: date}, end_date: {type: string, format: date, description: omit if current}, tasks: {type: array, items: {oneOf: [{type: string}, {type: object, properties: {custom: {type: string}}}]}}}
      responses: {'201': {description: Role}}
  /api/v1/roles/{role_id}/referees:
    post:
      operationId: add_referee
      summary: Nominate a referee (max 3 per role); returns a unique, single-use, 30-day referee link (no email sent)
      parameters: [{name: role_id, in: path, required: true, schema: {type: string}}]
      requestBody: {content: {application/json: {schema: {type: object, required: [name, email], properties: {name: {type: string}, email: {type: string}, phone: {type: string}, relationship: {type: string}}}}}}
      responses: {'201': {description: Referee link}, '409': {description: Role older than 24 months or max referees}}
  /api/v1/referee-questionnaire:
    get: {operationId: referee_questionnaire, security: [], summary: "Behaviour-anchored 1-5 questionnaire (learning & development, attitude); null = not observed. No protected-attribute questions.", responses: {'200': {description: Questionnaire}}}
  /api/ref/{token}:
    get: {operationId: referee_info, security: [], summary: Role, claimed tasks, questionnaire and consent text for the referee, parameters: [{name: token, in: path, required: true, schema: {type: string}}], responses: {'200': {description: Info}, '410': {description: Used/expired}}}
    post:
      operationId: referee_submit
      security: []
      summary: "Submit reference. Fraud signals recorded: self-reference, free webmail vs employer domain, ABN checksum, IP reuse, date mismatch."
      parameters: [{name: token, in: path, required: true, schema: {type: string}}]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [confirmed_worked, consent]
              properties:
                confirmed_worked: {type: boolean}
                title_correct: {type: boolean}
                title_corrected: {type: string}
                start_corrected: {type: string, format: date}
                end_corrected: {type: string, format: date}
                tasks: {type: array, items: {type: object, required: [task_id, witnessed], properties: {task_id: {type: string}, witnessed: {type: boolean}, level: {type: string, enum: [supervised, independent, can_train]}, recency: {type: string}, comment: {type: string}}}}
                answers: {type: object, additionalProperties: {type: object, properties: {score: {type: [integer, 'null'], minimum: 1, maximum: 5}, example: {type: string, maxLength: 300}}}}
                would_rehire: {type: boolean}
                comments: {type: string}
                consent: {type: boolean, const: true}
      responses: {'200': {description: Recorded}}
  /api/v1/verification:
    get: {operationId: get_verification, summary: "Your portable verified passport: summary score, learning/attitude (per referee + average + disagreement flag), certs, roles with per-task matrix (unverified | referee_verified | strongly_verified | disputed)", responses: {'200': {description: Passport}}}
  /api/v1/passport/share:
    post: {operationId: share_passport, summary: Create read-only passport link for any employer/bot (revokes previous), responses: {'201': {description: Link}}}
    delete: {operationId: revoke_passport, summary: Revoke passport link, responses: {'200': {description: Revoked}}}
  /api/passport/{token}:
    get: {operationId: get_passport, security: [], parameters: [{name: token, in: path, required: true, schema: {type: string}}], responses: {'200': {description: Passport}, '404': {description: Invalid/revoked}}}
  /api/v1/me:
    delete:
      operationId: delete_account
      summary: Permanently delete your account and all personal data (APP 11/13)
      requestBody: {content: {application/json: {schema: {type: object, required: [confirm], properties: {confirm: {type: string, const: DELETE}}}}}}
      responses: {'200': {description: Deleted}}
  /api/admin/certs/pending:
    get: {operationId: admin_pending_certs, security: [{admin: []}], summary: Certs awaiting register lookup/review with lookup URLs, responses: {'200': {description: Pending}}}
  /api/admin/certs/{cert_id}/review:
    post:
      operationId: admin_review_cert
      security: [{admin: []}]
      parameters: [{name: cert_id, in: path, required: true, schema: {type: string}}]
      requestBody: {content: {application/json: {schema: {type: object, required: [status], properties: {status: {type: string, enum: [verified, rejected, pending]}, method: {type: string}, note: {type: string}}}}}}
      responses: {'200': {description: Reviewed}}
  /api/v1/identity/email/start:
    post: {operationId: confirm_email, summary: "Email a single-use 24h confirmation link (identity +15). Outbox only until real email is enabled.", responses: {'202': {description: Sent}}}
  /api/identity/email/{token}:
    get: {operationId: confirm_email_token, security: [], parameters: [{name: token, in: path, required: true, schema: {type: string}}], responses: {'200': {description: Confirmed}, '404': {description: Invalid/used}, '410': {description: Expired}}}
  /api/v1/identity/phone/start:
    post: {operationId: confirm_phone, summary: Not enabled yet (paid SMS provider needed), responses: {'501': {description: Not implemented}}}
  /api/admin/identity/{account_id}:
    post:
      operationId: admin_set_identity
      security: [{admin: []}]
      summary: Set identity signals until Stripe/SMS are live (stripe_identity check is included in the subscription)
      parameters: [{name: account_id, in: path, required: true, schema: {type: string}}]
      requestBody: {content: {application/json: {schema: {type: object, properties: {phone_confirmed: {type: boolean}, card_name_match: {type: string, enum: [pending, match, mismatch]}, stripe_identity: {type: string, enum: [not_started, pending, verified, failed]}, abn_name_match: {type: boolean}}}}}}
      responses: {'200': {description: Updated identity score}}
  /api/v1/sourced/{listing_id}/claim:
    post:
      operationId: claim_listing
      summary: ABN-verified employer claims a sourced listing; converts to an H1R3 job (1 credit). Logged for moderation.
      parameters: [{name: listing_id, in: path, required: true, schema: {type: string}}]
      responses: {'201': {description: Job created}, '402': {description: No credits}, '403': {description: Not a verified employer}, '404': {description: Not found / already claimed}}
  /api/admin/sources:
    get: {operationId: admin_list_sources, security: [{admin: []}], summary: Ingestion sources with last run status, counts and live jobs, responses: {'200': {description: Sources}}}
    post:
      operationId: admin_add_source
      security: [{admin: []}]
      requestBody: {content: {application/json: {schema: {type: object, required: [kind, token], properties: {kind: {type: string, enum: [greenhouse, lever]}, token: {type: string}, name: {type: string}}}}}}
      responses: {'201': {description: Added/enabled}}
  /api/admin/sources/{source_id}/{action}:
    post: {operationId: admin_toggle_source, security: [{admin: []}], parameters: [{name: source_id, in: path, required: true, schema: {type: string}}, {name: action, in: path, required: true, schema: {type: string, enum: [enable, disable]}}], responses: {'200': {description: Updated}}}
  /api/admin/ingest/run:
    post:
      operationId: admin_ingest_run
      security: [{admin: []}]
      summary: Run ingestion now (n least-recently-run sources, max 5, or one source_id)
      requestBody: {content: {application/json: {schema: {type: object, properties: {n: {type: integer, maximum: 5}, source_id: {type: string}}}}}}
      responses: {'200': {description: Per-source results}}
  /bot-jobs.json:
    get:
      operationId: bot_jobs_index
      security: []
      summary: "Canonical index of BOT-APPLICABLE jobs (apply end-to-end machine-to-machine). ETag/If-None-Match supported. Same as /api/bot-jobs without filters."
      responses: {'200': {description: Index}, '304': {description: Not modified}}
  /api/bot-jobs:
    get:
      operationId: list_bot_applicable_jobs
      security: []
      parameters:
        - {name: q, in: query, schema: {type: string}}
        - {name: location, in: query, schema: {type: string}}
        - {name: state, in: query, schema: {type: string, enum: [QLD, NSW, VIC, WA, SA, TAS, ACT, NT]}}
        - {name: work_type, in: query, schema: {type: string}}
        - {name: min_pay, in: query, schema: {type: number}}
        - {name: skills, in: query, schema: {type: string, description: comma-separated}}
        - {name: requires_task, in: query, schema: {type: string}}
        - {name: updated_since, in: query, schema: {type: string, format: date-time}}
        - {name: limit, in: query, schema: {type: integer, maximum: 200}}
        - {name: cursor, in: query, schema: {type: string}}
      responses: {'200': {description: "{count,total,next_cursor,note?,jobs[{id,title,employer{name,abn_verified,trust},location,state,remote,pay,work_type,required_certs,required_tasks,posted_at,expires_at,apply{method,endpoint,http,required_fields,auth}}]}"}}
  /bot-apply-spec:
    get: {operationId: bot_apply_spec, security: [], summary: "Open spec (markdown): expose /.well-known/bot-apply.json so bots can apply to your jobs", responses: {'200': {description: Markdown}}}
  /api/admin/external-bot-sources:
    post:
      operationId: admin_validate_external
      security: [{admin: []}]
      summary: Validate an origin's /.well-known/bot-apply.json; valid jobs are included as external_bot_api, invalid ones removed
      requestBody: {content: {application/json: {schema: {type: object, required: [origin], properties: {origin: {type: string}}}}}}
      responses: {'200': {description: Valid}, '422': {description: Invalid}}
  /api/founding:
    get: {operationId: founding_status, security: [], summary: "Founding Employer program: cap 100, taken, remaining, terms (first 100 businesses Australia-wide; 12 months free + 10 job credits + badge; one per ABN)", responses: {'200': {description: Status}}}
  /api/admin/founding/{account_id}/{action}:
    post: {operationId: admin_founding, security: [{admin: []}], parameters: [{name: account_id, in: path, required: true, schema: {type: string}}, {name: action, in: path, required: true, schema: {type: string, enum: [grant, revoke]}}], responses: {'200': {description: Done}, '409': {description: No slot / ABN used}}}
  /api/careers-index:
    get: {operationId: careers_index, security: [], summary: "Public careers-site index: ATS, robots, structured data, anti-bot markers, login_required, ats_terms_verdict, can_list, candidate_bot_apply (no secrets)", responses: {'200': {description: Index + stats}}}
  /api/admin/careers:
    post: {operationId: admin_add_careers_site, security: [{admin: []}], requestBody: {content: {application/json: {schema: {type: object, required: [employer, url], properties: {employer: {type: string}, url: {type: string}, abn: {type: string}}}}}}, responses: {'201': {description: Added}}}
  /api/admin/careers/run:
    post: {operationId: admin_careers_run, security: [{admin: []}], summary: Re-check n due sites (max 6) or one id now, requestBody: {content: {application/json: {schema: {type: object, properties: {n: {type: integer}, id: {type: string}}}}}}, responses: {'200': {description: Results}}}
  /api/admin/careers/stats:
    get: {operationId: admin_careers_stats, security: [{admin: []}], summary: Index stats + recent changes, responses: {'200': {description: Stats}}}
  /go/{id}:
    get: {operationId: go_out, security: [], summary: "Click-out to the employer's original apply page (302). Counts clicks only; no personal data.", parameters: [{name: id, in: path, required: true, schema: {type: string}}], responses: {'302': {description: Redirect to original}, '404': {description: Unknown listing}}}
  /api/admin/careers/pages:
    post: {operationId: admin_careers_pages, security: [{admin: []}], summary: "Fetch up to 3 queued job pages for one site now (1 per call recommended)", requestBody: {content: {application/json: {schema: {type: object, required: [site_id], properties: {site_id: {type: string}, n: {type: integer}}}}}}, responses: {'200': {description: Results}}}
# Sourced search results include: work_type, posted, closes, excerpt (<=500 chars), direct_to {apply_url, go_url, method (human_form|candidate_bot_ok|candidate_bot_grey|bot_api), login_required, anti_bot_seen[], notes}
