# OpenAPI v1 — VanishCaptcha solves API

> **Learning / test project — not a live product.** HTTP contract for a personal test stack while learning AI usage platforms. Not a public SaaS API.

Implemented in `src/solver_api` + `src/solver_contract`. Staging server: `http://167.233.230.123:8787`. Staging clients use `wait=90` on create.

If this file and [HANDBOOK.md](../HANDBOOK.md) disagree, the handbook wins; then update this spec. Live behaviour: [staging-success.md](staging-success.md).

```yaml
openapi: 3.0.3
info:
  title: VanishCaptcha solves API (learning/test)
  version: 1.0.0
  description: |
    Learning/test project (not a live product). VanishCaptcha HTTP API for POST/GET /v1/solves.
servers:
  - url: http://127.0.0.1:8787
    description: Local (PORT)
  - url: http://167.233.230.123:8787
    description: Staging API VM (HTTP)
paths:
  /healthz:
    get:
      summary: Process is up
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    example: true
  /readyz:
    get:
      summary: Redis (and queue if ENGINE=ml) reachable
      responses:
        "200":
          description: Ready
        "503":
          description: Not ready
  /v1/solves:
    get:
      summary: List solves for the key's account, newest first
      description: |
        Cursor-paginated. The cursor is the id of the last item in `data`;
        pass it as `starting_after`. Rows carry no token: a page of them is a
        page of chances to leak one, so the token stays on GET /v1/solves/{id}.

        A key with no accountId (issued before accounts existed) lists nothing
        rather than erroring -- the key is valid and still solves, its solves
        were simply never indexed against an owner.
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: starting_after
          in: query
          description: A solve id from the previous page, exclusive.
          schema: { type: string }
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/SolveStatus"
        - name: type
          in: query
          schema:
            type: string
            enum: [hcaptcha, hcaptcha_enterprise]
      responses:
        "200":
          description: A page of solves
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SolveList"
        "400":
          description: starting_after is not a solve in this account's history
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "401":
          description: Invalid or revoked key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
    post:
      summary: Create a solve (long-poll up to wait seconds)
      parameters:
        - $ref: "#/components/parameters/Wait"
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateSolveRequest"
            example:
              type: hcaptcha_enterprise
              sitekey: "sitekey-from-iframe"
              url: "https://www.example.com/checkout"
              user_agent: "Mozilla/5.0 ..."
              rqdata: "enterprise-rqdata"
      responses:
        "200":
          description: Terminal result (solved or failed) within wait
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Solve"
        "202":
          description: Still pending or solving when wait elapsed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SolveIncomplete"
        "400":
          description: Invalid body
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "401":
          description: Invalid or revoked key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "429":
          description: Rate or concurrency limit
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
  /v1/solves/{id}:
    delete:
      summary: Cancel a pending or solving solve
      description: |
        A cancelled solve is never charged.

        A pending solve is genuinely stopped: it leaves the lane queue and no
        widget is opened. A solve already being played is marked cancelled and
        its slot released, but the worker is not interrupted -- reaching into a
        live session mid-play strands it and wastes the challenge -- so it may
        still finish and report solved. Not charged either way.
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: "^solve_[A-Za-z0-9]+" }
      responses:
        "200":
          description: Cancelled
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SolveSummary"
        "404":
          description: Unknown id, or a solve belonging to another account
        "409":
          description: Already terminal; not silently re-marked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
    get:
      summary: Get solve by id (long-poll up to wait seconds)
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: "^solve_[A-Za-z0-9]+"
        - $ref: "#/components/parameters/Wait"
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Terminal or current record
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Solve"
        "202":
          description: Still pending or solving when wait elapsed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SolveIncomplete"
        "401":
          description: Invalid key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
        "404":
          description: |
            Unknown id, expired id, or a solve belonging to another account.
            Deliberately indistinguishable: telling them apart would confirm
            that an id created by someone else's key is real. Same key always
            passes, so keys predating accounts keep reading their own solves.
            (message must match client regex)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
              example:
                error:
                  code: not_found
                  message: Solve does not exist or expired
  /v1/solves/{id}/feedback:
    post:
      summary: Report the downstream outcome for one solve
      description: |
        Same validation and upsert as one item of POST /v1/feedback. One item,
        so a per-item failure is the request's failure: unknown or not-yours is
        404, anything else invalid is 422.
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: "^solve_[A-Za-z0-9]+" }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FeedbackItemBody"
      responses:
        "200":
          description: Recorded
        "404":
          description: Unknown solve, or one belonging to another account
        "422":
          description: Bad outcome, unsolved solve, or outside the reporting window
  /v1/feedback:
    post:
      summary: Report downstream outcomes in a batch
      description: |
        Not all-or-nothing: each item is validated and upserted independently,
        so one stale id does not discard the rest. Read `results` per row. A
        batch over the cap is refused whole -- truncating silently would drop
        verdicts the caller believes they sent.

        Idempotent, last-write-wins: retrying a flush cannot leave two verdicts
        on one solve.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [feedback]
              properties:
                feedback:
                  type: array
                  minItems: 1
                  maxItems: 500
                  items:
                    $ref: "#/components/schemas/FeedbackItem"
      responses:
        "200":
          description: Per-item report
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackBatchResult"
        "422":
          description: feedback missing, empty, not an array, or over the cap
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"
  /v1/me:
    get:
      summary: The account behind this key, and its credit balance
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Account
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Account"
        "404":
          description: |
            The key is valid but has no account -- issued before accounts
            existed. Not a 401: the key still solves.
  /v1/stripe/webhook:
    post:
      summary: Stripe payment events (not called by customers)
      description: |
        Outside the dashboard router and outside bearer auth: Stripe has
        neither a session nor an API key. The `Stripe-Signature` header IS the
        authentication -- an unverified body never reaches the settle path,
        because without that check this endpoint reads "anyone who learns the
        URL can credit any account".

        Acts on checkout.session.completed and
        checkout.session.async_payment_succeeded, and returns 200 for anything
        else so Stripe does not retry a delivery we understood and ignored.
        Settlement is idempotent: a redelivery credits nothing.

        404 when Stripe is not configured.
      responses:
        "200":
          description: Handled, or deliberately ignored
        "400":
          description: Missing, malformed, stale or forged signature
        "404":
          description: Payments are not configured on this deployment
  /internal/telemetry/solve:
    post:
      summary: Client page-accept telemetry (no tokens)
      security:
        - adminOrWorker: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TelemetrySolve"
      responses:
        "204":
          description: Recorded
        "401":
          description: Unauthorized
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: nc_live
    adminOrWorker:
      type: http
      scheme: bearer
  parameters:
    Wait:
      name: wait
      in: query
      required: false
      description: Hold the HTTP request until terminal or this many seconds (clamp 1-90)
      schema:
        type: integer
        minimum: 1
        maximum: 90
        default: 30
  schemas:
    CreateSolveRequest:
      type: object
      additionalProperties: true
      required:
        - type
        - sitekey
        - url
        - user_agent
      properties:
        type:
          type: string
          enum: [hcaptcha, hcaptcha_enterprise]
        sitekey:
          type: string
          minLength: 1
        url:
          type: string
          minLength: 1
        user_agent:
          type: string
          minLength: 1
        rqdata:
          type: string
    SolveStatus:
      type: string
      enum: [pending, solving, solved, failed, cancelled, expired]
    ErrorObject:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          enum:
            - invalid_key
            - rate_limited
            - invalid_sitekey
            - challenge_timeout
            - unsupported_challenge
            - session_lost
            - proxy_blocked
            - solve_failed
            - expired
            - not_found
            - cancelled
            - conflict
            - validation_error
            - insufficient_credits
        message:
          type: string
    ErrorBody:
      type: object
      required: [error]
      properties:
        error:
          $ref: "#/components/schemas/ErrorObject"
    Account:
      type: object
      properties:
        object: { type: string, enum: [account] }
        id: { type: string }
        email: { type: string }
        email_verified: { type: boolean }
        credits_balance: { type: integer }
        created_at: { type: string, format: date-time, nullable: true }
    FeedbackItem:
      type: object
      required: [solve_id, outcome]
      properties:
        solve_id:
          type: string
          description: Your own solve, status solved, within 30 days.
        outcome:
          type: string
          enum: [accepted, rejected, unknown, unused, error]
          description: |
            Only accepted and rejected count toward an acceptance rate. The
            rest are recorded and excluded -- "never submitted" is not
            evidence about the token.
        reason: { type: string, maxLength: 512 }
        context: { type: string, maxLength: 2000 }
        reported_at:
          type: string
          description: Advisory; the server stamps its own.
    FeedbackItemBody:
      description: A FeedbackItem without solve_id, which comes from the path.
      type: object
      required: [outcome]
      properties:
        outcome:
          type: string
          enum: [accepted, rejected, unknown, unused, error]
        reason: { type: string, maxLength: 512 }
        context: { type: string, maxLength: 2000 }
        reported_at: { type: string }
    FeedbackBatchResult:
      type: object
      properties:
        object: { type: string, enum: [feedback.batch] }
        recorded: { type: integer }
        rejected: { type: integer }
        results:
          type: array
          items:
            type: object
            properties:
              solve_id: { type: string }
              ok: { type: boolean }
              outcome: { type: string }
              error: { type: string }
    SolveSummary:
      description: A solve as the list shows it. No token, sitekey, url or user agent.
      type: object
      required: [id, object, status]
      properties:
        id: { type: string }
        object: { type: string, enum: [solve] }
        type: { type: string, nullable: true }
        status:
          $ref: "#/components/schemas/SolveStatus"
        credits_charged:
          type: integer
          description: Flat per solved solve; zero for anything else.
        created_at: { type: string, format: date-time, nullable: true }
        finished_at:
          type: string
          format: date-time
          nullable: true
          description: Null while the solve is still pending or solving.
        error:
          allOf: [{ $ref: "#/components/schemas/ErrorObject" }]
          nullable: true
    SolveList:
      type: object
      required: [object, data, has_more]
      properties:
        object: { type: string, enum: [list] }
        data:
          type: array
          items:
            $ref: "#/components/schemas/SolveSummary"
        has_more:
          type: boolean
          description: |
            With a filter applied this means "there is more history to walk",
            not "there are more matches": the search is bounded per request.
    SolveIncomplete:
      type: object
      required: [id, status]
      properties:
        id:
          type: string
          pattern: "^solve_[A-Za-z0-9]+"
        status:
          type: string
          enum: [pending, solving]
    Solve:
      type: object
      required: [id, status]
      properties:
        id:
          type: string
          pattern: "^solve_[A-Za-z0-9]+"
        status:
          $ref: "#/components/schemas/SolveStatus"
        token:
          type: string
          description: Present only when status is solved
        error:
          $ref: "#/components/schemas/ErrorObject"
    TelemetrySolve:
      type: object
      additionalProperties: false
      required: [solveId, accepted]
      properties:
        solveId:
          type: string
        hasRqdata:
          type: boolean
        solveMs:
          type: integer
        pageHttp:
          type: integer
        accepted:
          type: boolean
```
