openapi: 3.1.0
info:
  title: Chitmark API
  description: |
    Score and route inbound actions (signup, trial, and similar surfaces):
    allow, challenge, or deny. Later outcome labels tune scoring.

    Product endpoints: `POST /v1/verify`, `POST /v1/feedback`,
    `POST /v1/challenge`. Ops surfaces are limited to `GET /health` and
    `GET /.well-known/jwks.json`.
    Authenticate product endpoints with `Authorization: Bearer <API_KEY>`.

    - Fail to challenge, never to allow. When `degraded` is true, decision is
      always `challenge` (never silent allow on timeout or uncertainty).
    - Persist `eventId` from every verify; feedback joins only on that id.
    - Verify is deterministic and fast (benchmarked at single-digit-ms p50 locally, budget
      10ms p50 / 50ms p95; CI gate 60ms p50 / 120ms p95).
    - PII defaults to hashed (`piiMode: hashed`); raw is opt-in.
    - Outcomes such as `credit_burn`, conversion, and chargeback are labels that
      tune scoring, not a payments or billing product.
    - `Idempotency-Key` header and body `idempotencyKey` both dedupe verify
      retries. If both are sent, `Idempotency-Key` takes precedence; SDKs set
      both to the same value.
  version: 0.1.0
  contact:
    name: Chitmark
    url: https://chitmark.com
  license:
    name: Proprietary

servers:
  - url: https://api.chitmark.com
    description: Production

security:
  - bearerAuth: []

tags:
  - name: verify
    description: Score and route an action (allow / challenge / deny)
  - name: feedback
    description: Report outcome labels joined on eventId
  - name: challenge
    description: Add friction after a challenge or degraded verdict
  - name: ops
    description: Non-product operational surfaces

paths:
  /health:
    get:
      operationId: health
      tags: [ops]
      summary: Health check
      description: Non-product ops surface for uptime checks.
      security: []
      responses:
        "200":
          description: Service health
          content:
            application/json:
              schema:
                type: object
                required: [ok, service, env]
                properties:
                  ok:
                    type: boolean
                  service:
                    type: string
                  env:
                    type: string

  /v1/verify:
    post:
      operationId: verify
      tags: [verify]
      summary: Score and route an inbound action
      description: |
        Deterministic and fast (benchmarked at single-digit-ms p50 locally, budget 10ms p50 /
        50ms p95; CI gate 60ms p50 / 120ms p95). Returns allow | challenge |
        deny with principal resolution, confidence, reasons, verdictToken, and
        eventId (persist on the account row for feedback).
        When degraded is true, decision is always challenge: fail to challenge,
        never to allow.
        Raw subject PII (`email`, `ip`, `formData`) is accepted only for
        tenants with explicit raw-mode opt-in. Default integrations should send
        `emailSha256`, `ipTrunc`, and `formDataHashed`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/VerifyRequest"
      responses:
        "200":
          description: Verdict (always returns, even when degraded)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Verdict"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: |
            The same Idempotency-Key was reused with a materially different
            verify payload.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /v1/feedback:
    post:
      operationId: feedback
      tags: [feedback]
      summary: Report an outcome labeled by eventId
      description: |
        Attribution is a schema contract: join only on eventId from verify.
        Labels (credit_burn, conversion, chargeback, multi_account_cluster,
        abuse_confirmed, …) tune scoring; they are not a payments product.
        Prefer fast labels that close the feedback loop quickly.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FeedbackRequest"
      responses:
        "200":
          description: Acknowledged
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FeedbackResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Unknown eventId
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Feedback ledger temporarily unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /v1/challenge:
    post:
      operationId: challenge
      tags: [challenge]
      summary: Issue or complete an asymmetric-cost challenge
      description: |
        Two modes on one verb. **Issue** (no `proof` in the body): used after a
        challenge or degraded verdict. Method preference via `prefer`, ranked
        best → worst; never defaults to email_otp. **Complete** (`challengeId` +
        `proof`): submit the solved proof; clears only the bound session, once.
        Wrong session, replay, expiry, wrong tenant, and invalid proofs fail
        with the documented error codes below.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ChallengeRequest"
      responses:
        "200":
          description: Challenge issued, or completion acknowledged
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/ChallengeResponse"
                  - $ref: "#/components/schemas/ChallengeCompletionResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Completion session does not match the issued boundTo
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: Unknown challengeId for this tenant
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: Challenge was already solved (anti-replay)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "410":
          description: Challenge has expired
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: Challenge state store unavailable
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /.well-known/jwks.json:
    get:
      operationId: getJwks
      tags: [verify]
      summary: JWKS for verdictToken verification
      security: []
      description: >-
        Public keys (ES256/P-256) that verify verdictToken signatures. `kid` is
        a fingerprint of the key (first 16 base64url chars of SHA-256 over
        `x:y`). During rotation the set may contain both the retiring and the
        new key. Response is cacheable: `Cache-Control: public, max-age=60`.
        When no signing key is configured, the set is empty and verdict tokens
        are unsigned.
      responses:
        "200":
          description: JSON Web Key Set
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: API key
      description: CHITMARK_API_KEY as Bearer token

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
      description: |
        Dedupes retries; one verdict per action attempt. If this header and
        body `idempotencyKey` are both sent, this header takes precedence.
        SDKs set both to the same value.

  responses:
    BadRequest:
      description: Invalid request body or missing required field
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Forbidden:
      description: Forbidden, including raw PII without tenant opt-in
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    RateLimited:
      description: Rate limited
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

  schemas:
    Decision:
      type: string
      enum: [allow, challenge, deny]

    ActorType:
      type: string
      enum: [human, agent_principal, agent_abusive, unknown]

    EnforcementMode:
      type: string
      enum: [enforce, log_only]
      description: |
        enforce verdicts can gate partner traffic. log_only verdicts are emitted
        for dashboards/observation without partner-side enforcement.

    Action:
      type: string
      enum:
        - signup
        - trial_activation
        - api_key
        - demo_request
        - quote
        - ticket
        - custom
      description: Or any custom action string the customer defines

    Outcome:
      type: string
      enum:
        - credit_burn
        - multi_account_cluster
        - abuse_confirmed
        - converted
        - chargeback
        - churned
        - false_positive

    ChallengeMethod:
      type: string
      enum:
        - payment_preauth
        - proof_of_work
        - device_attestation
        - web_bot_auth_stepup
        - email_otp

    Subject:
      type: object
      description: |
        Subject signals. Prefer hashed/tokenized fields (piiMode hashed).
        Raw email/ip are opt-in only.
      properties:
        email:
          type: string
          format: email
        emailSha256:
          type: string
          description: Client-side SHA-256 of lowercased email
        ip:
          type: string
        ipTrunc:
          type: string
          description: Truncated IP (/24 IPv4 or /48 IPv6)
        userAgent:
          type: string
        headers:
          type: object
          additionalProperties:
            oneOf:
              - type: string
              - type: array
                items:
                  type: string
          description: Includes Web Bot Auth headers when present
        formData:
          type: object
          additionalProperties: true
        formDataHashed:
          type: object
          additionalProperties: true

    Principal:
      type: object
      required: [resolved, identityBasis]
      properties:
        resolved:
          type: boolean
        operator:
          type: string
          description: Detected bot maker, e.g. openai
        onBehalfOf:
          type: string
          description: End-user principal when known; may be null
        identityBasis:
          type: array
          items:
            type: string

    VerifyRequest:
      type: object
      required: [action, subject]
      properties:
        idempotencyKey:
          type: string
          description: |
            Body form of the verify idempotency key. If the `Idempotency-Key`
            header is also sent, the header takes precedence.
        action:
          oneOf:
            - $ref: "#/components/schemas/Action"
            - type: string
        session:
          type: string
          description: Binds challenge and verdictToken to this session
        surface:
          type: string
          description: e.g. app.acme.com/signup
        subject:
          $ref: "#/components/schemas/Subject"
        context:
          type: object
          description: |
            Optional per-request policy flags.
            `logOnly` (true): emit an observation-only `log_only` verdict:
            nothing is blocked or challenged.
            `challengeId`: reference a challenge this partner completed for
            `session`; the next verify for that session honors it once (a
            cleared challenge yields an allow verdict with challengeCleared).
            `challengePolicy`: "default" (challenge the gray zone) or
            "confirmed_tail_only" (enforce mode only: challenge only the
            confirmed farm tail, allow the gray zone).
            `decisionCosts`: {falseAllow, falseChallenge, falseDeny}:
            positive numbers that reposition the allow/challenge/deny
            boundaries per request. Higher falseAllow challenges earlier
            (abuse-averse); higher falseChallenge lets more of the gray zone
            through; higher falseDeny reserves deny for confirmed abuse.
            Defaults 1/1/3 preserve the fail-to-challenge semantics. A field
            that is missing, not a number, or non-positive falls back to its
            default.
          additionalProperties: true

    Verdict:
      description: |
        Verify verdict. Constraint: when `degraded` is true, `decision` is
        always `challenge` (fail to challenge, never to allow).
      allOf:
        - type: object
          required:
            - decision
            - actorType
            - confidence
            - reasons
            - principal
            - verdictToken
            - eventId
            - degraded
            - enforcementMode
          properties:
            decision:
              $ref: "#/components/schemas/Decision"
            actorType:
              $ref: "#/components/schemas/ActorType"
            confidence:
              type: number
              minimum: 0
              maximum: 1
            reasons:
              type: array
              items:
                type: string
            principal:
              $ref: "#/components/schemas/Principal"
            verdictToken:
              type: string
              description: ES256 JWT decision receipt bound to session
            eventId:
              type: string
              description: "Store on account row: feedback join key"
            degraded:
              type: boolean
              description: If true, decision is always challenge
            enforcementMode:
              $ref: "#/components/schemas/EnforcementMode"
            challengeCleared:
              type: boolean
              description: |
                true when this allow verdict honors a challenge the partner
                completed for this session (verify context.challengeId).
        - if:
            required: [degraded]
            properties:
              degraded:
                const: true
          then:
            properties:
              decision:
                const: challenge

    FeedbackRequest:
      type: object
      required: [eventId, outcome]
      properties:
        eventId:
          type: string
        outcome:
          $ref: "#/components/schemas/Outcome"
        value:
          type: number
          description: Dollars of infra/credit or ARR
        unit:
          type: string
          default: usd
        observedAt:
          type: string
          format: date-time

    FeedbackResponse:
      type: object
      required: [ok, eventId]
      properties:
        ok:
          type: boolean
        eventId:
          type: string

    ChallengeRequest:
      type: object
      description: |
        Issue mode (no `proof`) or completion mode (`challengeId` + `proof`).
      required: [eventId]
      properties:
        eventId:
          type: string
          description: eventId from the verify verdict that routed to challenge
        session:
          type: string
          description: |
            Binds the challenge to this session; completion and the re-verify
            that honors it must present the same session (anti-replay).
        prefer:
          type: array
          description: Ranked method preference, best → worst. Never email_otp.
          items:
            $ref: "#/components/schemas/ChallengeMethod"
        context:
          type: object
          description: |
            Issue-mode context. `riskTier` scales the issued proof-of-work
            difficulty so the challenge's economic tax rises with risk;
            completion is always validated against the difficulty that was
            issued.
          properties:
            riskTier:
              type: string
              enum: [default, elevated, severe]
              description: |
                PoW difficulty tier. Absent or unknown → `default` (baseline
                difficulty, unchanged behavior).
          additionalProperties: false
        challengeId:
          type: string
          description: "Completion mode: the challenge being solved"
        proof:
          $ref: "#/components/schemas/ChallengeProof"

    ChallengeProof:
      type: object
      description: Server-verified completion proof, bound to challengeId + session.
      oneOf:
        - type: object
          required: [type, nonce]
          properties:
            type:
              const: proof_of_work
            nonce:
              type: string
              minLength: 1
              maxLength: 128
              description: |
                First nonce where sha256(`${challengeId}:${seed}:${nonce}`) in
                hex starts with `difficulty` zero nibbles.
        - type: object
          required: [type, setupIntentId]
          properties:
            type:
              const: stripe_setup_intent
            setupIntentId:
              type: string
              description: |
                The SetupIntent id from the issued instructions after Stripe
                reports status `succeeded` (test-mode); stub mode accepts the
                issued `seti_stub_<challengeId>` id.

    ChallengeResponse:
      type: object
      required: [challengeId, method, instructions, boundTo, expiresAt]
      properties:
        challengeId:
          type: string
        method:
          $ref: "#/components/schemas/ChallengeMethod"
        instructions:
          oneOf:
            - $ref: "#/components/schemas/PowInstructions"
            - $ref: "#/components/schemas/StripeSetupIntentInstructions"
        boundTo:
          type: string
          description: Session the solve clears (anti-replay)
        expiresAt:
          type: string
          format: date-time

    PowInstructions:
      type: object
      required: [type, difficulty, seed, algorithm]
      properties:
        type:
          const: pow
        difficulty:
          type: integer
          minimum: 1
          maximum: 12
          description: Leading zero nibbles required in the sha256 hex digest
        seed:
          type: string
          description: Per-challenge seed; combine as `${challengeId}:${seed}:${nonce}`
        algorithm:
          const: sha256-prefix

    StripeSetupIntentInstructions:
      type: object
      required: [type, mode, clientSecret]
      properties:
        type:
          const: stripe_setup_intent
        mode:
          type: string
          enum: [test, stub]
          description: |
            `test`: real Stripe test-mode SetupIntent (Stripe secret wired).
            `stub`: deterministic `seti_stub_<challengeId>` for testing
            without Stripe credentials.
        clientSecret:
          type: string
        publishableKey:
          type: string
          description: Present when mode is test (pk_test_...)

    ChallengeCompletionResponse:
      type: object
      required: [ok, challengeId, boundTo, clearedAt]
      properties:
        ok:
          const: true
        challengeId:
          type: string
        boundTo:
          type: string
          description: Session that was cleared (single use, anti-replay)
        clearedAt:
          type: string
          format: date-time

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: string
        message:
          type: string
        code:
          type: string
