openapi: 3.1.0
info:
  title: CookieComply Public API
  version: "1.5.0"
  description: |
    Public API for CookieComply workspaces — for MCP-compatible agents,
    coding assistants, scripts, and CI.

    Authenticate with `Authorization: Bearer <access_token>` from
    Settings → Connect AI & automation (Professional+).

    Full-access tokens can run analyses. Read-only practice tokens can list
    and export existing scans but cannot call analyze, cookie research, or ask-ai.

    Permissions (scopes) control what a token may do — view scans, run analyses,
    export reports, see usage, manage members.

    Capture cookies in a browser (Chrome add-on, Playwright, Browser Use), then
    `POST /analyze` with before / after-Accept / optional declined (Reject) cookie
    lists. CookieComply does not browse URLs itself. Call `POST /validate-capture`
    first to catch bad captures without consuming a scan.

    Human guide: https://cookie-comply.com/integrations
    Agent playbook: https://cookie-comply.com/skills/cookiecomply/SKILL.md
  contact:
    name: CookieComply
    url: https://cookie-comply.com
servers:
  - url: https://cookie-comply.com/api/v1
    description: Production
  - url: http://localhost:3000/api/v1
    description: Local
security:
  - bearerAuth: []
tags:
  - name: Scans
  - name: Analyze
  - name: Reports
  - name: Ask AI
  - name: Usage
  - name: Cookies
  - name: Workspace
  - name: Members
paths:
  /scans:
    get:
      operationId: listScans
      tags: [Scans]
      summary: List workspace scans
      description: Requires `scans:read`. Invalid page/limit return 400.
      parameters:
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
        - name: status
          in: query
          schema: { type: string }
        - name: q
          in: query
          description: URL search
          schema: { type: string }
        - name: clientTag
          in: query
          description: Filter by lightweight client tag (e.g. client:acme)
          schema: { type: string }
      responses:
        "200":
          description: Paginated scans
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScanList" }
        "400":
          description: Invalid pagination
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /scans/{id}:
    get:
      operationId: getScan
      tags: [Scans]
      summary: Get scan with findings/report JSON
      description: Requires `scans:read`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Full scan
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Scan" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Not found
    patch:
      operationId: setScanClientTag
      tags: [Scans]
      summary: Set or clear a client tag on a scan
      description: |
        Requires `scans:write`. Lightweight agency filter tag (e.g. `client:acme`).
        Pass `clientTag: null` or `""` to clear.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SetScanClientTagRequest" }
      responses:
        "200":
          description: Updated tag
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SetScanClientTagResult" }
        "400":
          description: clientTag required
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Scan not found
  /scans/diff:
    get:
      operationId: diffScans
      tags: [Scans]
      summary: Compare two completed scans
      description: Requires `scans:read`. Returns added/removed/category-changed cookies and post-reject regressions.
      parameters:
        - name: base
          in: query
          required: true
          schema: { type: string }
        - name: compare
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Diff result
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScanDiff" }
        "400":
          description: Missing params
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Scan not found
  /scans/{id}/sign-off:
    post:
      operationId: signOffScan
      tags: [Scans]
      summary: Customer sign-off on scan inventory
      description: |
        Requires `scans:write`. Records who/when for export attestation.
        CookieComply does not audit — this is the customer's attestation.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SignOffScanRequest" }
      responses:
        "200":
          description: Signed off
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SignOffScanResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Already signed off
  /scans/{id}/policy-compare:
    post:
      operationId: comparePolicy
      tags: [Scans]
      summary: Compare agent-submitted cookie/privacy policy against a scan
      description: |
        Requires `scans:write`. Agents find the site's privacy/cookie policy in the browser,
        then submit `policyUrl` + `policyText` and/or structured `declaredCookies`.
        CookieComply diffs declarations against the scan and persists `policyConflicts`
        on the scan results. **Does not fetch** customer policy URLs server-side.
        Returns 400 `policy_text_required` when only a URL is provided without text/declarations.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ComparePolicyRequest" }
      responses:
        "200":
          description: Policy conflicts computed and persisted
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ComparePolicyResult" }
        "400":
          description: Missing policy text/declarations or invalid body
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Scan not found
  /scans/{id}/export:
    get:
      operationId: exportScan
      tags: [Reports]
      summary: Export scan report (CSV, JSON, Markdown, or PDF)
      description: |
        Requires `reports:read`. Formats: `csv`, `json`, `md`, `pdf`.
        PDF is a lightweight text report generated server-side (not a browser print).
        Use `GET /scans/{id}` for raw findings JSON (`scans:read`).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: format
          in: query
          schema:
            type: string
            enum: [csv, json, md, pdf]
            default: csv
      responses:
        "200":
          description: Report file body
          content:
            text/csv: { schema: { type: string } }
            application/json: { schema: { type: object } }
            text/markdown: { schema: { type: string } }
            application/pdf:
              schema: { type: string, format: binary }
        "400":
          description: Invalid format
        "404":
          description: Not found
  /scans/{id}/ask-ai:
    post:
      operationId: askAi
      tags: [Ask AI]
      summary: Ask AI a question about a scan report
      description: |
        Requires `scans:read` and a **live** (`cc_live_`) key.
        Test keys return 403 `test_key_not_allowed` (Vertex burn).
        Subject to per-scan question limits by plan.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [userQuestion]
              properties:
                userQuestion: { type: string }
      responses:
        "200":
          description: AI answer
          content:
            application/json:
              schema:
                type: object
                properties:
                  answer: { type: string }
                  questionCount: { type: integer }
                  messages: { type: array, items: { type: object } }
        "403":
          description: Limit or tier
        "404":
          description: Not found
  /analyze:
    post:
      operationId: analyzeCookies
      tags: [Analyze]
      summary: Submit cookie capture payload for analysis (consumes one scan)
      description: |
        Requires `scans:write` and a **live** (`cc_live_`) key.
        Returns 403 `no_scans` when balance is empty;
        403 `test_key_not_allowed` for `cc_test_` keys.
      parameters:
        - name: Idempotency-Key
          in: header
          schema: { type: string }
          description: Optional; concurrent retries share one claim (24h)
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AnalyzeRequest" }
      responses:
        "200":
          description: Analysis result
        "400":
          description: >
            Invalid payload or capture quality failure
            (`capture_incomplete`, `consent_not_detected`, `empty_cookies`)
            — no scan credit consumed. Soft codes like
            `declined_looks_like_preconsent` may appear on 200 responses.
        "403":
          description: No scans remaining (`no_scans`) or not entitled
        "409":
          description: Idempotency conflict still in progress
  /validate-capture:
    post:
      operationId: validateCapture
      tags: [Analyze]
      summary: Validate a cookie capture without analyzing or charging
      description: |
        Requires `scans:write`. Does **not** create a scan or consume credits.
        Use before `POST /analyze` to catch incomplete or failed consent clicks.
        Accepts the same body shape as AnalyzeRequest.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AnalyzeRequest" }
      responses:
        "200":
          description: Capture is analyzable (may include soft warnings)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CaptureValidation" }
        "400":
          description: Hard validation failure
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CaptureValidation" }
  /usage:
    get:
      operationId: getUsage
      tags: [Usage]
      summary: Remaining scans and subscription summary
      description: |
        Requires `billing:read`. Safe read surface only — Stripe Customer Portal
        stays behind the authenticated dashboard (`billingPortal: dashboard_only`).
      responses:
        "200":
          description: Usage
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }
  /workspace:
    get:
      operationId: getWorkspace
      tags: [Workspace]
      summary: Workspace metadata for the API key
      description: Requires `workspace:read`.
      responses:
        "200":
          description: Workspace
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Workspace" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /workspace/branding:
    patch:
      operationId: updateWorkspaceBranding
      tags: [Workspace]
      summary: Update white-label report branding
      description: |
        Requires `scans:write`. Sets firm name and/or logo URL used on PDF/Markdown exports.
        Provide at least one of `reportFirmName` or `reportLogoUrl`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UpdateWorkspaceBrandingRequest" }
      responses:
        "200":
          description: Updated branding fields
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkspaceBranding" }
        "400":
          description: Invalid branding payload
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /members:
    get:
      operationId: listMembers
      tags: [Members]
      summary: List workspace members and pending invitations
      description: Requires `members:read` (opt-in; not in default key scopes).
      responses:
        "200":
          description: Members and invitations
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MembersList" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
  /members/invitations:
    post:
      operationId: inviteMember
      tags: [Members]
      summary: Invite a member by email
      description: |
        Requires `members:write`. Role is always MEMBER.
        Returns `acceptUrl` so callers can share the link if email delivery is skipped.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
                locale: { type: string }
      responses:
        "200":
          description: Invitation created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/InviteResult" }
        "400":
          description: Invalid email
        "409":
          description: Already a member
  /members/invitations/{id}:
    delete:
      operationId: revokeInvitation
      tags: [Members]
      summary: Revoke a pending invitation
      description: Requires `members:write`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
        "404":
          description: Not found
  /members/{memberId}:
    delete:
      operationId: removeMember
      tags: [Members]
      summary: Remove a workspace member
      description: Requires `members:write`. Cannot remove the workspace owner.
      parameters:
        - name: memberId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
        "400":
          description: Cannot remove owner
        "404":
          description: Not found
  /cookies/research:
    post:
      operationId: researchCookies
      tags: [Cookies]
      summary: Smart Cookie Review research for unknown cookies
      description: |
        Requires `scans:write` and a **live** (`cc_live_`) key.
        Vertex research is capped per request (batch size) and rate-limited
        per workspace (~30 batches/hour).
        Returns 429 `research_rate_limited` when exhausted;
        403 `test_key_not_allowed` for `cc_test_` keys.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [cookies]
              properties:
                cookies:
                  type: array
                  maxItems: 40
                  items:
                    type: object
                    required: [name, domain]
                    properties:
                      name: { type: string }
                      domain: { type: string }
      responses:
        "200":
          description: Research results
        "429":
          description: Research rate limited (`research_rate_limited`)
  /cookies/confirm:
    post:
      operationId: confirmCookies
      tags: [Cookies]
      summary: Confirm cookie categories (learns patterns)
      description: |
        Requires `scans:write`. Allowed with `cc_test_` keys (no Vertex burn).
        Optional Idempotency-Key.
      parameters:
        - name: Idempotency-Key
          in: header
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ConfirmCookiesRequest" }
      responses:
        "200":
          description: Confirmations recorded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ConfirmCookiesResult" }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        API key (`cc_live_…` or `cc_test_…`).
        Test keys read live workspace data but cannot call analyze,
        cookie research, or ask-ai (`test_key_not_allowed`).
  responses:
    Unauthorized:
      description: Missing or invalid API key (`unauthorized`)
    Forbidden:
      description: |
        Insufficient scope (`insufficient_scope`), plan not entitled
        (`api_not_entitled`), no scans remaining (`no_scans`), or test key
        blocked on live-only routes (`test_key_not_allowed`)
  schemas:
    ScanSummary:
      type: object
      properties:
        id: { type: string }
        url: { type: string }
        status: { type: string }
        createdAt: { type: string, format: date-time }
        analysisId: { type: string, nullable: true }
    ScanList:
      type: object
      properties:
        scans:
          type: array
          items: { $ref: "#/components/schemas/ScanSummary" }
        totalCount: { type: integer }
        page: { type: integer }
        limit: { type: integer }
    Scan:
      allOf:
        - $ref: "#/components/schemas/ScanSummary"
        - type: object
          properties:
            results: { type: object }
            analysisId: { type: string, nullable: true }
            beforeData: { type: object, nullable: true }
            afterData: { type: object, nullable: true }
            declinedData: { type: object, nullable: true }
            selectedCategories: { type: object, nullable: true }
            signedOffAt: { type: string, format: date-time, nullable: true }
            signedOffByName: { type: string, nullable: true }
            clientTag: { type: string, nullable: true }
    CaptureValidation:
      type: object
      properties:
        ok: { type: boolean }
        canAnalyze: { type: boolean }
        counts:
          type: object
          properties:
            before: { type: integer }
            after: { type: integer }
            declined: { type: integer }
        issues:
          type: array
          items:
            type: object
            properties:
              code:
                type: string
                enum:
                  [
                    ok,
                    capture_incomplete,
                    consent_not_detected,
                    declined_looks_like_preconsent,
                    empty_cookies,
                    invalid_url,
                  ]
              message: { type: string }
              severity: { type: string, enum: [hard, soft] }
    AnalyzeRequest:
      type: object
      required: [url, cookies]
      properties:
        url: { type: string, format: uri }
        locale: { type: string }
        analysisId: { type: string }
        clientResolved:
          type: boolean
          description: >
            Categories already assigned on the client (e.g. Smart Review Confirm).
            Skips analyze auto-enrich only when jars already have usable categories
            (non-empty, not Uncategorized). Flag alone with empty/uncategorized
            cookies still runs enrichment.
        manualCategorization:
          type: boolean
          description: >
            User overrode one or more cookie categories. Same skip rule as
            clientResolved: skip auto-enrich only when jars already have usable
            categories.
        cookies:
          type: object
          description: >
            Same conceptual shape as the Chrome extension: cookies before
            consent, after Accept, and optionally after Reject (`declined`).
            Agents using Playwright should send context.cookies() mapped to
            CookieCaptureItem. Alias `afterReject` is accepted for `declined`.
          required: [before]
          properties:
            before:
              type: array
              items: { $ref: "#/components/schemas/CookieCaptureItem" }
            after:
              type: array
              description: Post-Accept snapshot
              items: { $ref: "#/components/schemas/CookieCaptureItem" }
            declined:
              type: array
              description: Post-Reject / decline-all snapshot
              items: { $ref: "#/components/schemas/CookieCaptureItem" }
            afterReject:
              type: array
              description: Alias for declined
              items: { $ref: "#/components/schemas/CookieCaptureItem" }
            localStorage:
              type: array
              items: { $ref: "#/components/schemas/StorageItem" }
            sessionStorage:
              type: array
              items: { $ref: "#/components/schemas/StorageItem" }
    CookieCaptureItem:
      type: object
      required: [name, domain]
      properties:
        name: { type: string }
        domain: { type: string }
        value: { type: string }
        path: { type: string }
        secure: { type: boolean }
        httpOnly: { type: boolean }
        sameSite: { type: string }
        expirationDate: { type: number }
        session: { type: boolean }
    StorageItem:
      type: object
      properties:
        key: { type: string }
        value: { type: string }
    Usage:
      type: object
      properties:
        scansRemaining: { type: integer, description: "-1 means unlimited" }
        tier: { type: string, nullable: true }
        billingPeriod: { type: string, nullable: true }
        subscriptionStatus: { type: string, nullable: true }
        billingPortal:
          type: string
          enum: [dashboard_only]
          description: Stripe portal is not available via API keys
        workspace:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            slug: { type: string }
    Workspace:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        slug: { type: string }
        scansRemaining: { type: integer }
        tier: { type: string, nullable: true }
    UpdateWorkspaceBrandingRequest:
      type: object
      properties:
        reportFirmName: { type: string, nullable: true, maxLength: 200 }
        reportLogoUrl: { type: string, nullable: true, maxLength: 2000 }
    WorkspaceBranding:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        reportFirmName: { type: string, nullable: true }
        reportLogoUrl: { type: string, nullable: true }
    SetScanClientTagRequest:
      type: object
      required: [clientTag]
      properties:
        clientTag:
          type: string
          nullable: true
          maxLength: 120
          description: Tag value, or null/empty to clear
    SetScanClientTagResult:
      type: object
      properties:
        id: { type: string }
        clientTag: { type: string, nullable: true }
    SignOffScanRequest:
      type: object
      properties:
        name: { type: string, description: Display name for the attestation }
    SignOffScanResult:
      type: object
      properties:
        ok: { type: boolean }
        scanId: { type: string }
        signedOffAt: { type: string, format: date-time }
        signedOffByName: { type: string }
    ComparePolicyRequest:
      type: object
      description: >
        Agent-submitted policy content. Provide policyText and/or declaredCookies.
        policyUrl alone is not enough (no server fetch).
      properties:
        policyUrl:
          type: string
          nullable: true
          description: URL the agent opened (for reference only; not fetched)
        policyText:
          type: string
          nullable: true
          description: Extracted page text or markdown from the policy page
        declaredCookies:
          type: array
          items:
            type: object
            required: [name]
            properties:
              name: { type: string }
              purpose: { type: string }
              claimedCategory: { type: string }
    ComparePolicyResult:
      type: object
      properties:
        ok: { type: boolean }
        scanId: { type: string }
        policyUrl: { type: string, nullable: true }
        declaredCount: { type: integer }
        conflictCount: { type: integer }
        conflicts:
          type: array
          items:
            type: object
            properties:
              type:
                type: string
                enum:
                  - missing_from_policy
                  - category_mismatch
                  - policy_marks_essential
                  - policy_only_unobserved
              cookieName: { type: string }
              detail: { type: string }
              severity:
                type: string
                enum: [high, medium, low]
              scannedCategory: { type: string }
              claimedCategory: { type: string }
    ScanDiff:
      type: object
      description: Cookie inventory delta between two completed scans
      additionalProperties: true
    ConfirmCookiesRequest:
      type: object
      required: [confirmations]
      properties:
        confirmations:
          type: array
          items:
            type: object
            required: [name, domain, category]
            properties:
              name: { type: string }
              domain: { type: string }
              category:
                type: string
                enum: [Essential, Functional, Analytics, Marketing, Preference]
              previousCategory: { type: string }
        scanId:
          type: string
          description: Attach an audit trail row to this scan
    ConfirmCookiesResult:
      type: object
      properties:
        ok: { type: boolean }
        count: { type: integer }
    MembersList:
      type: object
      properties:
        workspace:
          type: object
          properties:
            id: { type: string }
            name: { type: string }
            slug: { type: string }
        members:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              role: { type: string }
              user:
                type: object
                properties:
                  id: { type: string }
                  name: { type: string }
                  email: { type: string }
        invitations:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              email: { type: string }
              role: { type: string }
              expiresAt: { type: string, format: date-time }
    InviteResult:
      type: object
      properties:
        ok: { type: boolean }
        invitation:
          type: object
          properties:
            id: { type: string }
            email: { type: string }
            role: { type: string }
            expiresAt: { type: string, format: date-time }
            acceptUrl: { type: string }
        email:
          type: object
          properties:
            sent: { type: boolean }
            configured: { type: boolean }
            skipped: { type: boolean }
