# Source: fingerprint-guard docs/openapi/fpchecker-api.yaml at commit f8a89f3 (API 0.49.0). Copied unmodified below this line; do not edit here.
openapi: 3.1.0
info:
  title: FPChecker API
  version: 0.49.0
  summary: The customer-facing HTTP API of FPChecker.
  description: |
    Identify a device, decide whether it may claim or use something, and read what was seen.
    Hand-written from the route handlers; `worker/test/openapi-contract.test.ts` checks it against the running
    server (the route set, the examples and the real answers).

    **Keys.** A project has a secret key (`sk_…`, header `X-Secret-Key`, backend only) and a publishable key
    (`pk_…`, header `X-Publishable-Key`, safe in a browser or app). `/v2/challenge` and `/v2/identify` take the
    publishable key; every other keyed route takes the secret key. The wrong kind of key is `401`.

    **Errors.** Most failures are `{ "ok": false, "error": "<code>" }` (`Error`). Routes that make a decision
    or record something answer in their own shape: `{ "ok": false, "reason": "<code>" }` plus the fields the
    route normally has (`EligibilityRefusal`, `QuotaRefusal`, `ReleaseRefusal`, `SubjectRefusal`,
    `FeedbackRefusal`, `IdentifyRefusal`, `ReasonError`). Some failures are deliberately HTTP 200 with
    `ok: false` (a fail-closed internal error, `maintenance`, most identify refusals): **test `ok`, not only the
    status**. Never treat a response without `ok: true` as a success.

    **Billing.** On a metered project a billed call with an empty balance is `402 { "ok": false, "error":
    "insufficient_credits" }`, and `503 commit_uncertain` means the charge may have happened. A billed call is
    never safe to retry blindly.

    **Forward compatibility.** Clients must tolerate unknown enum values in response fields (for example `reason`,
    `error` and signal kinds) and unknown object properties. New values and properties may be added without a
    version bump; treat an unrecognised value as a generic refusal or ignore it, never as a parse failure.

    **Management API.** Routes under `/v1/management/` take a management key (`mk_…`, header
    `Authorization: Bearer`) instead of a project key, and act on the whole account: its projects, environments,
    webhooks, rulesets, usage and audit log. Keys have a `read` or a `write` scope. Answers are
    `{ "ok": true, … }` or `{ "ok": false, "error": "<code>" }`. Each account may make 120 reads and 30 changes
    per minute. Billing, members, management keys, signing keys, proxy secrets and domains are not on this plane.

    **Not in this file.** Admin, console and network-check routes, and the older v1 challenge and identify
    protocol, are not part of the customer contract. The production API serves no OpenAPI route of its own.
servers:
  - url: https://api.fpchecker.com
    description: Production
security: []
tags:
  - name: Identify
    description: Browser and app side (publishable key), plus the backend call that starts it.
  - name: Decisions
    description: Backend calls that spend an attestation (secret key).
  - name: Events
    description: Read and annotate what identify recorded (secret key).
  - name: Bots
    description: Header-only bot check for requests your backend received (secret key).
  - name: Operations
    description: Liveness and status (no key).
  - name: Management
    description: Manage the account's projects, environments, webhooks and rulesets from your own tooling (management key). Separate from the project keys above.
paths:
  /v2/intents:
    post:
      operationId: createIntent
      tags: [Identify]
      summary: Create an intent for the next identify
      description: |
        Your backend names what the next identify is for. The returned `intentToken` goes to your client, which
        passes it to `GET /v2/challenge` and `POST /v2/identify`. Stateless and free. Treat the token as opaque.
        A fail-closed server error is `200 { "ok": false, "error": "temporarily_unavailable" }`.
      security:
        - secretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IntentRequest'
            examples:
              trial:
                value: { purpose: 'claim:trial', subject: user-123, clientIp: 203.0.113.7 }
      responses:
        '200':
          description: The intent, or a fail-closed refusal (check `ok`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/IntentResponse'
                  - $ref: '#/components/schemas/Error'
              examples:
                created:
                  value: { ok: true, intentToken: fpi1.eyJwIjoicHJqXzAxMjM0NTY3ODlhYiJ9.c2ln, expiresIn: 600 }
        '400':
          description: '`bad_request`, `bad_purpose` or `bad_subject`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                badPurpose:
                  value: { ok: false, error: bad_purpose }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v2/challenge:
    get:
      operationId: getChallenge
      tags: [Identify]
      summary: Get a challenge for an intent
      description: |
        Called by the client (publishable key). The `intent` must be URL-encoded. An intent can fetch several
        challenges until it expires (for example a second identify after a proof-of-work request).
      security:
        - publishableKey: []
      parameters:
        - name: intent
          in: query
          required: true
          description: The `intentToken` your backend created.
          schema: { type: string }
        - name: difficulty
          in: query
          required: false
          description: Ask for a proof-of-work challenge of this many zero bits (8 to 28).
          schema: { type: integer, minimum: 8, maximum: 28 }
      responses:
        '200':
          description: The challenge, or a fail-closed refusal (check `ok`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ChallengeResponse'
                  - $ref: '#/components/schemas/Error'
              examples:
                issued:
                  value: { ok: true, protocolVersion: 2, challenge: v4.2c9f0b7a1d.5e3a, expiresIn: 300 }
        '400':
          description: '`bad_intent` (missing, expired or another project''s) or `bad_difficulty`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                badIntent:
                  value: { ok: false, error: bad_intent }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403':
          description: '`project_disabled`, or `request_filtered` when the project''s request filters (allowed origins, IP deny list, header rules, crawler blocking) refuse the request. A filtered request is not billed and records no event.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                disabled:
                  value: { ok: false, error: project_disabled }
                filtered:
                  value: { ok: false, error: request_filtered }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v2/identify:
    post:
      operationId: identify
      tags: [Identify]
      summary: Identify a device
      description: |
        Called by the client (publishable key) with the components it collected. The answer carries one opaque
        `attestation` for your backend to spend, the `eventId` of the recorded event and, when the project has
        sealed results enabled, a `sealedResult` your backend decrypts with the project's sealing key.

        Refusals are mostly HTTP 200 with `ok: false` and a `reason` (`IdentifyRefusal`); a rate limit or the
        daily identify cap is 429, no credits is 402. **A success without a non-empty string `attestation` is a
        failure.**
      security:
        - publishableKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IdentifyRequest'
            examples:
              web:
                value:
                  protocolVersion: 2
                  platform: web
                  intentToken: fpi1.eyJwIjoicHJqXzAxMjM0NTY3ODlhYiJ9.c2ln
                  challenge: v4.2c9f0b7a1d.5e3a
                  agentVersion: 0.3.0
                  components:
                    - { key: fpjs.timezone, value: Europe/Berlin }
                  linkedId: order-123
                  tags: { plan: pro }
              android:
                value:
                  protocolVersion: 2
                  platform: android
                  intentToken: fpi1.eyJwIjoicHJqXzAxMjM0NTY3ODlhYiJ9.c2ln
                  challenge: v4.2c9f0b7a1d.5e3a
                  agentVersion: android-0.1.0
                  components:
                    - { key: android.id, value: 1a2b3c4d5e6f7081 }
                  proof: { type: play_integrity, token: eyJhbGciOi }
      responses:
        '200':
          description: The attestation, or a refusal (check `ok`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/IdentifyResponse'
                  - $ref: '#/components/schemas/IdentifyRefusal'
              examples:
                sealed:
                  summary: Web identify of a project with sealed results on
                  value:
                    ok: true
                    protocolVersion: 2
                    attestation: fpa1.Zm9v.YmFy
                    expiresIn: 600
                    eventId: ev_0123456789ABCDEFGHJK
                    sealedResult: ZnBzMQECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4v
                native:
                  summary: Native identify, no sealed results
                  value:
                    ok: true
                    protocolVersion: 2
                    attestation: fpa1.Zm9v.YmFy
                    expiresIn: 600
                    eventId: ev_0123456789ABCDEFGHJK
                    proofStatus: checked
                refused:
                  value: { ok: false, protocolVersion: 2, reason: bad_intent }
        '400':
          description: '`invalid_linked_id` or `invalid_tags` (reason and error both set).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IdentifyRefusal' }
              examples:
                badTags:
                  value: { ok: false, protocolVersion: 2, reason: invalid_tags, error: invalid_tags }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403':
          description: '`project_disabled`, or `request_filtered` when the project''s request filters (allowed origins, IP deny list, header rules, crawler blocking) refuse the request. A filtered request is not billed and records no event.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                disabled:
                  value: { ok: false, error: project_disabled }
                filtered:
                  value: { ok: false, error: request_filtered }
        '429':
          description: Rate limited, or `identify_daily_limit` on a metered account.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/IdentifyRefusal'
                  - $ref: '#/components/schemas/RateLimitError'
              examples:
                limit:
                  value: { ok: false, protocolVersion: 2, reason: identify_daily_limit }
  /v1/eligibility:
    post:
      operationId: checkEligibility
      tags: [Decisions]
      summary: Decide whether this device may claim, and optionally record the claim
      description: |
        Backend only. Spends the attestation from identify. With `claim: true` an `allow` records one claim
        atomically and the answer carries its `claimId`. Business outcomes (deny, turnstile, pow) are HTTP 200
        with `ok: true` and an `action`: grant only on `eligible === true`. Billed; never retried automatically.
        Pass the real end-user IP as `clientIp`.

        **Attestation refusals.** Only a missing or non-object `attestation` is HTTP 400. An expired or altered
        attestation, one issued for another purpose, or one whose subject differs is HTTP 200 with `ok: false`
        and `reason` `bad_attestation`, `attestation_purpose`, `attestation_subject` or (when the project requires
        intents) `intent_required`: **test `ok`, not only the status**.
      security:
        - secretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EligibilityRequest'
            examples:
              claim:
                value:
                  attestation: fpa1.Zm9v.YmFy
                  email: user@example.org
                  claim: true
                  clientIp: 203.0.113.7
                  kind: trial
      responses:
        '200':
          description: The decision (check `eligible` and `action`), or a fail-closed refusal.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/EligibilityResponse'
                  - $ref: '#/components/schemas/EligibilityRefusal'
              examples:
                allowed:
                  value:
                    ok: true
                    visitorId: v4_visitor1
                    confidence: 92
                    identityConfidence: 92
                    trustScore: 85
                    isNew: true
                    eligible: true
                    action: allow
                    reason: ok
                    claimId: '1042'
                needsCheck:
                  value:
                    ok: true
                    visitorId: v4_visitor1
                    confidence: 92
                    identityConfidence: 92
                    trustScore: 85
                    isNew: true
                    eligible: false
                    action: turnstile
                    reason: new_device_verification
                internal:
                  value: { ok: false, eligible: false, action: deny, reason: internal_error }
        '400':
          description: '`bad_attestation`: the `attestation` is missing or not an object.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EligibilityRefusal' }
              examples:
                missing:
                  value: { ok: false, eligible: false, action: deny, reason: bad_attestation }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '429':
          description: '`rate_limited` or `project_write_budget`.'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/EligibilityRefusal'
                  - $ref: '#/components/schemas/RateLimitError'
              examples:
                limited:
                  value: { ok: false, eligible: false, action: deny, reason: rate_limited }
        '503': { $ref: '#/components/responses/CommitUncertain' }
  /v1/eligibility/release:
    post:
      operationId: releaseEligibility
      tags: [Decisions]
      summary: Give a granted claim back
      description: |
        Backend only. Idempotent: releasing a released claim answers `released: true` with
        `already_released`. A refusal to release is HTTP 200 with `ok: true` and `released: false`.
      security:
        - secretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReleaseRequest'
            examples:
              release:
                value: { claimId: '1042' }
      responses:
        '200':
          description: The outcome (check `released`), or a fail-closed refusal.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ReleaseResponse'
                  - $ref: '#/components/schemas/ReleaseRefusal'
              examples:
                released:
                  value: { ok: true, claimId: '1042', released: true, reason: released }
                missing:
                  value: { ok: true, claimId: '1042', released: false, reason: claim_not_found }
        '400':
          description: '`bad_request`: the claim id is malformed.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReleaseRefusal' }
              examples:
                malformed:
                  value: { ok: false, released: false, reason: bad_request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '429':
          description: '`rate_limited` or `project_write_budget`.'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/ReleaseRefusal'
                  - $ref: '#/components/schemas/RateLimitError'
              examples:
                limited:
                  value: { ok: false, released: false, reason: rate_limited }
        '503': { $ref: '#/components/responses/CommitUncertain' }
  /v1/quota:
    post:
      operationId: checkQuota
      tags: [Decisions]
      summary: Check or consume a windowed usage quota, or give a use back
      description: |
        Backend only. Same envelope as eligibility, without an email. Without `consume` it is a read-only probe;
        with `consume` an `allow` records one use (an `idempotencyKey` makes a retry count once). With
        `release: true` and the `idempotencyKey` it gives that use back and needs no attestation. A quota name
        the project has not configured is denied, never unlimited. Billed; never retried automatically.

        **Attestation refusals.** Only a missing or non-object `attestation` is HTTP 400. An expired or altered
        attestation, one issued for another purpose, or one whose subject differs is HTTP 200 with `ok: false`
        and `reason` `bad_attestation`, `attestation_purpose`, `attestation_subject` or (when the project requires
        intents) `intent_required`: **test `ok`, not only the status**.
      security:
        - secretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/QuotaRequest'
            examples:
              consume:
                value:
                  attestation: fpa1.Zm9v.YmFy
                  quota: api-calls
                  consume: true
                  idempotencyKey: req-8841
                  clientIp: 203.0.113.7
              release:
                value: { quota: api-calls, release: true, idempotencyKey: req-8841 }
      responses:
        '200':
          description: The decision (check `allowed` and `action`), the release outcome, or a fail-closed refusal.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/QuotaResponse'
                  - $ref: '#/components/schemas/QuotaReleaseResponse'
                  - $ref: '#/components/schemas/QuotaRefusal'
              examples:
                allowed:
                  value:
                    ok: true
                    visitorId: v4_visitor1
                    identityConfidence: 92
                    trustScore: 85
                    quota: api-calls
                    allowed: true
                    action: allow
                    reason: ok
                    limit: 10
                    used: 3
                    remaining: 7
                    windowSeconds: 86400
                released:
                  value: { ok: true, quota: api-calls, released: true }
                internal:
                  value: { ok: false, allowed: false, action: deny, reason: internal_error }
        '400':
          description: '`bad_attestation` (the `attestation` is missing or not an object) or `bad_request`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/QuotaRefusal' }
              examples:
                missing:
                  value: { ok: false, allowed: false, action: deny, reason: bad_attestation }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '429':
          description: '`rate_limited` or `project_write_budget`.'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/QuotaRefusal'
                  - $ref: '#/components/schemas/RateLimitError'
              examples:
                limited:
                  value: { ok: false, allowed: false, action: deny, reason: rate_limited }
        '503': { $ref: '#/components/responses/CommitUncertain' }
  /v1/subjects/bind:
    post:
      operationId: bindSubject
      tags: [Decisions]
      summary: Bind a subject (for example an inviter) to the attested device
      description: |
        Backend only. The attestation must come from an intent created for purpose `subjects` with the same
        `subject`. Billed; never retried automatically.

        **Attestation refusals.** Only a missing or non-object `attestation` is HTTP 400. An expired or altered
        attestation, one issued for another purpose, or one whose subject differs is HTTP 200 with `ok: false`
        and `reason` `bad_attestation`, `attestation_purpose`, `attestation_subject` or (when the project requires
        intents) `intent_required`: **test `ok`, not only the status**.
      security:
        - secretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubjectRequest'
            examples:
              bind:
                value: { subject: user-9, attestation: fpa1.Zm9v.YmFy }
      responses:
        '200':
          description: Bound, or a fail-closed refusal (check `ok`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/BindResponse'
                  - $ref: '#/components/schemas/SubjectRefusal'
              examples:
                bound:
                  value: { ok: true, visitorId: v4_visitor1, bound: true }
        '400':
          description: '`bad_request`, `bad_subject`, or `bad_attestation` (the `attestation` is missing or not an object).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubjectRefusal' }
              examples:
                missing:
                  value: { ok: false, reason: bad_subject }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '429':
          description: '`rate_limited` or `project_write_budget`.'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/SubjectRefusal'
                  - $ref: '#/components/schemas/RateLimitError'
              examples:
                limited:
                  value: { ok: false, reason: rate_limited }
        '503': { $ref: '#/components/responses/CommitUncertain' }
  /v1/subjects/relation:
    post:
      operationId: relateSubjects
      tags: [Decisions]
      summary: Compare the attested device with a subject's bound devices
      description: |
        Backend only. Read only; `suggestion` is advice, the decision is yours. A refusal carries
        `suggestion: review` so a caller that reads only the suggestion never sees an allow from a check that did
        not run. Billed; never retried automatically.

        **Attestation refusals.** Only a missing or non-object `attestation` is HTTP 400. An expired or altered
        attestation, one issued for another purpose, or one whose subject differs is HTTP 200 with `ok: false`
        and `reason` `bad_attestation`, `attestation_purpose`, `attestation_subject` or (when the project requires
        intents) `intent_required`: **test `ok`, not only the status**.
      security:
        - secretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubjectRequest'
            examples:
              relation:
                value: { subject: user-9, attestation: fpa1.Zm9v.YmFy }
      responses:
        '200':
          description: The comparison, or a fail-closed refusal (check `ok`).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/RelationResponse'
                  - $ref: '#/components/schemas/SubjectRefusal'
              examples:
                unrelated:
                  value:
                    ok: true
                    visitorId: v4_visitor1
                    sameDevice: false
                    sameBrowserProfile: false
                    sameMachine: false
                    sameNetwork: false
                    sharedDeviceClass: false
                    subjectDevices: 1
                    subjectBound: true
                    subjectSaturated: false
                    suggestion: allow
                refused:
                  value: { ok: false, reason: internal_error, suggestion: review }
        '400':
          description: '`bad_request`, `bad_subject`, or `bad_attestation` (the `attestation` is missing or not an object).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SubjectRefusal' }
              examples:
                missing:
                  value: { ok: false, reason: bad_attestation, suggestion: review }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '429':
          description: '`rate_limited`.'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/SubjectRefusal'
                  - $ref: '#/components/schemas/RateLimitError'
              examples:
                limited:
                  value: { ok: false, reason: rate_limited, suggestion: review }
        '503': { $ref: '#/components/responses/CommitUncertain' }
  /v1/feedback:
    post:
      operationId: sendFeedback
      tags: [Decisions]
      summary: Say what a visitor or claim turned out to be
      description: |
        Backend only. Labels are input for offline analysis and never change a live decision. Exactly one of
        `visitorId` and `claimId`. Not charged on metered projects (a zero balance still answers 402).
      security:
        - secretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FeedbackRequest'
            examples:
              abuse:
                value: { claimId: '1042', label: abuse, reason: chargeback }
              legit:
                value: { visitorId: v4_visitor1, label: legit }
      responses:
        '200':
          description: Recorded, or a fail-closed refusal (`recorded` is false).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FeedbackResponse'
                  - $ref: '#/components/schemas/FeedbackRefusal'
              examples:
                recorded:
                  value: { ok: true, recorded: true }
                internal:
                  value: { ok: false, recorded: false, reason: internal_error }
        '400':
          description: '`bad_request`: both or neither id, a bad `label` or `reason`, or a malformed id.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FeedbackRefusal' }
              examples:
                bad:
                  value: { ok: false, recorded: false, reason: bad_request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientCredits' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '404':
          description: The claim is not this project's, or the visitor was not seen in the last 90 days.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/FeedbackRefusal' }
              examples:
                unknown:
                  value: { ok: false, recorded: false, reason: not_found }
        '429':
          description: '`rate_limited` or `project_write_budget`.'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FeedbackRefusal'
                  - $ref: '#/components/schemas/RateLimitError'
              examples:
                limited:
                  value: { ok: false, recorded: false, reason: rate_limited }
        '503': { $ref: '#/components/responses/CommitUncertain' }
  /v1/claims/flags:
    get:
      operationId: getClaimFlags
      tags: [Decisions]
      summary: List claims later found to share a household
      description: |
        Backend only. Oldest first. `next` is the cursor for the following page (pass it as `after`). It is
        `null` on the last page, but also non-null when a page is exactly full, so the page after it can be empty:
        loop until `next` is null.
      security:
        - secretKey: []
      parameters:
        - name: after
          in: query
          required: false
          description: The `flagId` to continue after. Default `0`.
          schema: { type: string, pattern: '^(0|[1-9][0-9]{0,14})$' }
        - name: limit
          in: query
          required: false
          description: Page size, 1 to 500. Default 100.
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        '200':
          description: One page of flags.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ClaimFlagsResponse' }
              examples:
                page:
                  value:
                    ok: true
                    flags:
                      - { flagId: '17', claimId: '1042', relatedClaimId: '977', kind: trial, reason: same_household, flaggedAt: 1792924550563 }
                    next: null
        '400':
          description: '`bad_request`: a malformed `after` or `limit`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReasonError' }
              examples:
                bad:
                  value: { ok: false, reason: bad_request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '429': { $ref: '#/components/responses/RateLimitedReason' }
        '500':
          description: Fail-closed server error (never an empty page).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReasonError' }
              examples:
                internal:
                  value: { ok: false, reason: internal_error }
  /v1/bot-check:
    post:
      operationId: botCheck
      tags: [Bots]
      summary: Check a request your backend received
      description: |
        Backend only. Forward the method, URL, client address and headers of a request you received and get back
        who or what sent it. Header-only: no script ran in the sender, so `bot.types` holds only the types the
        headers give away (`automation_tool`, `headless`, `missing_headers`). The address facts (`ip`, `vpn`,
        `proxy`) come from our own data; there is no device clock, so no `timezone_mismatch`. Not charged, and no
        event is written.

        `bot.result` is `good` for a verified or signed directory bot, `bad` for a directory bot that is not
        (`unknown` or `spoofed`) or for any request whose headers gave it away, and `not_detected` otherwise.
        Only signers listed in the Bot directory can be `signed`; a valid signature from anyone else is ignored.

        Send `url` as the visitor requested it: the original host and scheme, not the address of your origin behind
        a CDN or load balancer. A signed request is checked against that host. Chrome sends no client hints over
        plain http, so the missing-client-hints rule only applies to `https` URLs.

        Forward only the headers you need, never `Cookie` or `Authorization`: `user-agent`, `accept`,
        `accept-language`, `accept-encoding`, `sec-ch-ua`, `sec-ch-ua-mobile`, `sec-ch-ua-platform`,
        `sec-fetch-site`, `sec-fetch-mode`, `sec-fetch-dest`, `sec-fetch-user`, `signature`, `signature-input`,
        `signature-agent`, `upgrade-insecure-requests`, and every header named in the request's `Signature-Input`
        (a covered header you leave out makes the signature uncheckable: the identity is `unknown`). The body is at
        most 32 KiB, with at most 64 headers (values up to 4096 characters) and a `url` up to 2048 characters.

        `ip` is the client address your platform vouches for, never a value the visitor can set: Express `req.ip`
        with a hop-count `trust proxy`; Next.js the header your own proxy sets (for example `x-real-ip`), never the
        leftmost `X-Forwarded-For` entry. An IPv4-mapped IPv6 address is read as the IPv4 address.

        Call it once per visitor request and reuse the answer. Never retry a signed request: the second attempt
        replays the nonce and comes back `spoofed`.

        Limits: 1200 requests per minute per project (`429` with `reason: rate_limited`) and 3000 per minute per
        calling address, checked before authentication (`429` with `error: rate_limited`). Treat both bodies as
        rate limited.
      security:
        - secretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BotCheckRequest'
            examples:
              browser:
                value:
                  method: GET
                  url: 'https://shop.example/pricing'
                  ip: 203.0.113.7
                  headers:
                    user-agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36'
                    accept: text/html
                    accept-language: en-US
                    sec-ch-ua: '"Chromium";v="126"'
                    sec-fetch-site: none
      responses:
        '200':
          description: The verdict.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BotCheckResponse' }
              examples:
                clean:
                  value:
                    bot: { result: not_detected, types: [], info: null }
                    ip: { country: DE, asn: 64500, asName: Example Hosting GmbH, datacenter: false, tor: false, relay: false, blocklist: { result: false, sources: [] } }
                    vpn: { result: false, methods: [] }
                    proxy: { result: false, type: null }
                tool:
                  value:
                    bot: { result: bad, types: [automation_tool], info: null }
                    ip: { country: null, asn: null, asName: null, datacenter: null, tor: null, relay: null, blocklist: { result: null, sources: [] } }
                    vpn: { result: null, methods: [] }
                    proxy: { result: null, type: null }
        '400':
          description: '`invalid_request` with the offending `field` (`body`, `method`, `url`, `headers`), or `invalid_ip` for `ip`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/BotCheckInvalid' }
              examples:
                url:
                  value: { ok: false, error: invalid_request, field: url }
                ip:
                  value: { ok: false, error: invalid_ip, field: ip }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimitedReason' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/events:
    get:
      operationId: searchEvents
      tags: [Events]
      summary: Search events
      description: |
        Newest first, scoped to the caller's project. Any combination of the parameters; an unknown, repeated or
        malformed parameter is `400 invalid_request`. The signal filters match the event's `signals`; an event whose
        value is `null` matches neither `true` nor `false`.
      security:
        - secretKey: []
      parameters:
        - name: visitorId
          in: query
          description: Exact match.
          schema: { type: string, pattern: '^[A-Za-z0-9_-]{1,80}$' }
        - name: linkedId
          in: query
          description: Exact match.
          schema: { type: string, minLength: 1, maxLength: 256 }
        - name: ip
          in: query
          description: One address, or a CIDR no wider than IPv4 /16 or IPv6 /48.
          schema: { type: string }
        - name: suspect
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: platform
          in: query
          schema: { $ref: '#/components/schemas/Platform' }
        - name: bot
          in: query
          description: '`true` matches events where the bot result is `bad` (good bots are not counted), `false` those where it is `not_detected`. An event whose bot result is `good` matches neither `true` nor `false`; in a rule the field `bot` is `false` for a good bot.'
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: bot_result
          in: query
          description: Exact match on `signals.bot.result`. Events stored before server 0.48.0 have no `good`.
          schema: { $ref: '#/components/schemas/BotResult' }
        - name: bot_category
          in: query
          description: Exact match on `signals.bot.info.category`; events without a directory match have none.
          schema: { $ref: '#/components/schemas/BotCategory' }
        - name: bot_identity
          in: query
          description: Exact match on `signals.bot.info.identity`.
          schema: { $ref: '#/components/schemas/BotIdentity' }
        - name: bot_id
          in: query
          description: Exact match on `signals.bot.info.id`, one of the ids in the Bot directory.
          schema: { $ref: '#/components/schemas/BotId' }
        - name: vpn
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: proxy
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: tor
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: incognito
          in: query
          description: Private browsing detection in Chrome and Firefox on desktop. Events from every other browser, and native events, have `null`, which matches neither `true` nor `false`.
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: tampering
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: virtualMachine
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: rooted
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: hooked
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: emulator
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: mitm
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: locationSpoofed
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: clonedApp
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: start
          in: query
          description: ISO 8601 date or time. Default the last 7 days (or the retention, if shorter). The span cannot exceed the project's retention.
          schema: { type: string }
        - name: end
          in: query
          description: ISO 8601 date or time. Default now.
          schema: { type: string }
        - name: limit
          in: query
          description: 1 to 100.
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          description: The `next` of the previous page.
          schema: { type: string }
      responses:
        '200':
          description: One page of events.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EventPage' }
              examples:
                page:
                  value:
                    events: []
                    next: null
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '429': { $ref: '#/components/responses/RateLimitedReason' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/events/{eventId}:
    parameters:
      - $ref: '#/components/parameters/EventId'
    get:
      operationId: getEvent
      tags: [Events]
      summary: Get one event
      security:
        - secretKey: []
      responses:
        '200':
          description: The event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Event' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimitedReason' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      operationId: updateEvent
      tags: [Events]
      summary: Annotate an event
      description: |
        Any of `linkedId`, `tags` and `suspect`; `null` clears `linkedId` and `tags`. An unknown field, an empty
        body or an invalid value is `400 invalid_request` and changes nothing.
      security:
        - secretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EventPatch' }
            examples:
              flag:
                value: { suspect: true, tags: { reviewed: true } }
              clear:
                value: { linkedId: null }
      responses:
        '200':
          description: The updated event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Event' }
        '400': { $ref: '#/components/responses/InvalidRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimitedReason' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/visitors/{visitorId}:
    parameters:
      - name: visitorId
        in: path
        required: true
        schema: { type: string, pattern: '^[A-Za-z0-9_-]{1,80}$' }
    get:
      operationId: getVisitor
      tags: [Events]
      summary: Summarise a visitor
      description: '`404` for a visitor this project has not seen within its retention.'
      security:
        - secretKey: []
      responses:
        '200':
          description: The visitor summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Visitor' }
              examples:
                visitor:
                  value:
                    visitorId: v4_visitor1
                    firstSeenAt: '2026-10-20T08:00:00.000Z'
                    lastSeenAt: '2026-10-25T09:30:00.000Z'
                    eventCount: 4
                    linkedIds: [user_123]
                    recentEvents: []
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimitedReason' }
        '500': { $ref: '#/components/responses/InternalError' }
    delete:
      operationId: deleteVisitor
      tags: [Events]
      summary: Erase a visitor for this project
      description: |
        Erases this project's events, queued and past webhook deliveries and device rows for the visitor.
        `shared: true` means another project also uses the device, whose rows were kept. A second call is `404`.
        If the rate limiter itself fails the call refuses with `503 unavailable` and deletes nothing: retry it.
      security:
        - secretKey: []
      responses:
        '200':
          description: Erased.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/VisitorDeleted' }
              examples:
                deleted:
                  value: { deleted: true, shared: false }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/ProjectDisabled' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimitedReason' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503':
          description: The rate limiter is unavailable; nothing was deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              examples:
                unavailable:
                  value: { ok: false, error: unavailable }
  /health:
    get:
      operationId: getHealth
      tags: [Operations]
      summary: Is the process answering
      description: Proves the process answers; it stays green with the database or cache down. Use `/status` for that.
      security: []
      responses:
        '200':
          description: Alive.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Health' }
              examples:
                alive:
                  value: { ok: true, version: 0.49.0, env: production, commit: 6bd97c0 }
  /status:
    get:
      operationId: getStatus
      tags: [Operations]
      summary: Can the API serve identify and claims right now
      description: Booleans only, cached for 15 seconds. `503` with the same body when a dependency is down or the server is in maintenance.
      security: []
      responses:
        '200':
          description: Healthy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Status' }
              examples:
                up:
                  value: { ok: true, version: 0.49.0, maintenance: false, checks: { api: true, database: true, cache: true }, checkedAt: '2026-10-25T09:30:00.000Z' }
        '503':
          description: Not able to serve.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Status' }
              examples:
                down:
                  value: { ok: false, version: 0.49.0, maintenance: false, checks: { api: true, database: false, cache: true }, checkedAt: '2026-10-25T09:30:00.000Z' }
  /v1/management/account:
    get:
      operationId: managementGetAccount
      tags: [Management]
      summary: Get the account
      description: Status, credit balance and the most recent credit lots (up to 200, newest first). Needs the `read` scope.
      security:
        - managementKey: []
      responses:
        '200':
          description: The account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementAccountResponse' }
              examples:
                account:
                  value:
                    ok: true
                    account: { id: acc_0123456789abcdef, status: active, balance: 9500, grantedTotal: 10000, consumedTotal: 500, nextExpiryAt: '2027-10-26T00:00:00.000Z', createdAt: '2026-10-26T08:00:00.000Z' }
                    lots: [{ id: 1, amount: 10000, remaining: 9500, grantedAt: '2026-10-26T08:00:00.000Z', expiresAt: '2027-10-26T00:00:00.000Z', expiredAt: null }]
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/management/usage:
    get:
      operationId: managementGetUsage
      tags: [Management]
      summary: Daily usage
      description: Billed calls per day, project and endpoint. At most 92 days per request; the default is the last 30 days. Needs the `read` scope.
      security:
        - managementKey: []
      parameters:
        - name: from
          in: query
          description: First day, `YYYY-MM-DD` (UTC). Default 29 days before `to`.
          schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
        - name: to
          in: query
          description: Last day, `YYYY-MM-DD` (UTC). Default today.
          schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
      responses:
        '200':
          description: Usage rows, oldest day first.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementUsageResponse' }
              examples:
                usage:
                  value: { ok: true, rows: [{ day: '2026-10-26', projectId: prj_0123456789ab, endpoint: identify, n: 12 }] }
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/management/audit:
    get:
      operationId: managementListAudit
      tags: [Management]
      summary: Account audit log
      description: |
        Every change made to the account through the console or the Management API, newest first. Entries are kept
        for 400 days. Page with `nextCursor`. Needs the `read` scope.
      security:
        - managementKey: []
      parameters:
        - name: limit
          in: query
          description: 1 to 100.
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: cursor
          in: query
          description: The `nextCursor` of the previous page.
          schema: { type: string, pattern: '^\d{1,18}\.\d{1,18}$' }
        - name: action
          in: query
          description: Exact action name, for example `project.update`.
          schema: { type: string, minLength: 1, maxLength: 128 }
        - name: actorId
          in: query
          description: Exact actor id (a user id, or a management key id such as `mkey_0123456789ab`).
          schema: { type: string, minLength: 1, maxLength: 128 }
        - name: from
          in: query
          description: Inclusive lower bound, ISO 8601 date or date-time.
          schema: { type: string }
        - name: to
          in: query
          description: Exclusive upper bound, ISO 8601 date or date-time.
          schema: { type: string }
      responses:
        '200':
          description: One page of entries.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementAuditPage' }
              examples:
                page:
                  value:
                    ok: true
                    items:
                      - { id: 41, at: '2026-10-26T09:00:00.000Z', actor: { type: management_key, id: mkey_0123456789ab, label: ci }, action: project.update, target: { type: project, id: prj_0123456789ab }, detail: { fields: [name] } }
                    nextCursor: null
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/management/projects:
    get:
      operationId: managementListProjects
      tags: [Management]
      summary: List projects
      description: The account's projects, environments included, oldest first. Needs the `read` scope.
      security:
        - managementKey: []
      responses:
        '200':
          description: The projects.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementProjectList' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      operationId: managementCreateProject
      tags: [Management]
      summary: Create a project
      description: The new project's `secretKey` and `publishableKey` are in this answer and nowhere else. Needs the `write` scope.
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ManagementProjectCreate' }
            examples:
              create:
                value: { name: Shop, allowedOrigins: ['https://shop.example'] }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementProjectCreated' }
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '403': { $ref: '#/components/responses/ManagementInsufficientScope' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '423': { $ref: '#/components/responses/ManagementAccountReview' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementCommitUncertain' }
  /v1/management/projects/{projectId}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      operationId: managementGetProject
      tags: [Management]
      summary: Get a project
      description: Needs the `read` scope.
      security:
        - managementKey: []
      responses:
        '200':
          description: The project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementProjectResponse' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    patch:
      operationId: managementUpdateProject
      tags: [Management]
      summary: Update a project
      description: |
        Any of the listed fields; an empty body is `400 nothing_to_update`. Setting `disabled` to `false` on an
        environment counts against the limit of three enabled environments (`409 environment_limit`).
        A body containing `claimGroupId` is refused with `400 invalid_field`: claim groups are console-only.
        Needs the `write` scope.
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ManagementProjectPatch' }
            examples:
              rename:
                value: { name: Shop (EU) }
      responses:
        '200':
          description: The updated project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementProjectResponse' }
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '403': { $ref: '#/components/responses/ManagementInsufficientScope' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '409': { $ref: '#/components/responses/ManagementConflict' }
        '423': { $ref: '#/components/responses/ManagementAccountReview' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementCommitUncertain' }
    delete:
      operationId: managementDeleteProject
      tags: [Management]
      summary: Delete a project
      description: |
        Only a project that was never used can be deleted; otherwise `409 project_in_use` (disable it instead).
        A project that still has environments is `409 environments_remaining`. Needs the `write` scope.
      security:
        - managementKey: []
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementProjectDeleted' }
              examples:
                deleted:
                  value: { ok: true, deleted: prj_0123456789ab }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '403': { $ref: '#/components/responses/ManagementInsufficientScope' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '409': { $ref: '#/components/responses/ManagementConflict' }
        '423': { $ref: '#/components/responses/ManagementAccountReview' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementCommitUncertain' }
  /v1/management/projects/{projectId}/rotate:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      operationId: managementRotateKey
      tags: [Management]
      summary: Rotate a project key
      description: Replaces the project's secret or publishable key. The old key stops working at once; the new one is in this answer and nowhere else. Needs the `write` scope.
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ManagementRotate' }
            examples:
              secret:
                value: { key: secret }
      responses:
        '200':
          description: The new key.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementRotated' }
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '403': { $ref: '#/components/responses/ManagementInsufficientScope' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '423': { $ref: '#/components/responses/ManagementAccountReview' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementCommitUncertain' }
  /v1/management/projects/{projectId}/environments:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      operationId: managementCreateEnvironment
      tags: [Management]
      summary: Create an environment
      description: |
        An environment is a project of its own under a top-level project, with its own keys and events. It starts
        with a copy of the parent's settings (policy, allowed origins, request filters, ruleset, event retention, and
        the native and human-check credentials); webhooks, signing keys, proxy secrets, domains and claim groups are
        not copied. At most three enabled environments per project (ten in total, disabled ones included).
        `skipped` lists settings that could not be copied. Needs the `write` scope.
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ManagementEnvironmentCreate' }
            examples:
              staging:
                value: { name: staging }
      responses:
        '201':
          description: Created. The new keys are in this answer and nowhere else.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementEnvironmentCreated' }
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '403': { $ref: '#/components/responses/ManagementInsufficientScope' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '409': { $ref: '#/components/responses/ManagementConflict' }
        '423': { $ref: '#/components/responses/ManagementAccountReview' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementCommitUncertain' }
  /v1/management/projects/{projectId}/copy-settings:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      operationId: managementCopySettings
      tags: [Management]
      summary: Copy settings from another environment
      description: |
        Overwrites this project's settings with those of another project of the same family (the parent or a sibling
        environment). `404 project_not_found` for anything that is not in the same family. Needs the `write` scope.
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ManagementCopySettings' }
            examples:
              copy:
                value: { from: prj_0123456789ab }
      responses:
        '200':
          description: The updated project.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementSettingsCopied' }
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '403': { $ref: '#/components/responses/ManagementInsufficientScope' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '423': { $ref: '#/components/responses/ManagementAccountReview' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementCommitUncertain' }
  /v1/management/projects/{projectId}/webhooks:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      operationId: managementListWebhooks
      tags: [Management]
      summary: List webhook endpoints
      description: Needs the `read` scope.
      security:
        - managementKey: []
      responses:
        '200':
          description: The endpoints. Secrets are never listed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementWebhookList' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    post:
      operationId: managementCreateWebhook
      tags: [Management]
      summary: Create a webhook endpoint
      description: |
        The URL must be `https` and resolve to a public address. The signing `secret` is in this answer and nowhere
        else. A project has a limited number of endpoints (`409 limit_reached`). Needs the `write` scope.
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ManagementWebhookCreate' }
            examples:
              create:
                value: { url: 'https://hooks.example.com/fpchecker' }
      responses:
        '201':
          description: Created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementWebhookCreated' }
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '403': { $ref: '#/components/responses/ManagementInsufficientScope' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '409': { $ref: '#/components/responses/ManagementConflict' }
        '423': { $ref: '#/components/responses/ManagementAccountReview' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementCommitUncertain' }
  /v1/management/projects/{projectId}/webhooks/{webhookId}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/WebhookId'
    patch:
      operationId: managementUpdateWebhook
      tags: [Management]
      summary: Enable or disable a webhook endpoint
      description: Needs the `write` scope.
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ManagementWebhookPatch' }
            examples:
              disable:
                value: { enabled: false }
      responses:
        '200':
          description: The endpoint.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementWebhookResponse' }
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '403': { $ref: '#/components/responses/ManagementInsufficientScope' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '423': { $ref: '#/components/responses/ManagementAccountReview' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementCommitUncertain' }
    delete:
      operationId: managementDeleteWebhook
      tags: [Management]
      summary: Delete a webhook endpoint
      description: Needs the `write` scope.
      security:
        - managementKey: []
      responses:
        '200':
          description: Deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementWebhookDeleted' }
              examples:
                deleted:
                  value: { ok: true, deleted: wh_0123456789ABCDEF }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '403': { $ref: '#/components/responses/ManagementInsufficientScope' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '423': { $ref: '#/components/responses/ManagementAccountReview' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementCommitUncertain' }
  /v1/management/projects/{projectId}/webhooks/{webhookId}/rotate:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/WebhookId'
    post:
      operationId: managementRotateWebhookSecret
      tags: [Management]
      summary: Rotate a webhook signing secret
      description: The new `secret` is in this answer and nowhere else. Needs the `write` scope.
      security:
        - managementKey: []
      responses:
        '200':
          description: The new secret.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementWebhookRotated' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '403': { $ref: '#/components/responses/ManagementInsufficientScope' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '423': { $ref: '#/components/responses/ManagementAccountReview' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementCommitUncertain' }
  /v1/management/projects/{projectId}/ruleset:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      operationId: managementGetRuleset
      tags: [Management]
      summary: Get the ruleset
      description: Needs the `read` scope.
      security:
        - managementKey: []
      responses:
        '200':
          description: The stored ruleset (`version` 0 when none was ever saved).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementRulesetResponse' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
    put:
      operationId: managementPutRuleset
      tags: [Management]
      summary: Save the ruleset
      description: |
        Replaces the whole ruleset. `ifVersion` is the `version` this edit was based on; if the stored version has
        moved the answer is `409 version_conflict` with the current `version`, and nothing is saved. A rejected
        ruleset is `400 invalid_ruleset` with the `path` of the first problem. Needs the `write` scope.
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ManagementRulesetPut' }
            examples:
              save:
                value:
                  ifVersion: 0
                  enabled: true
                  rules:
                    - { name: Block hosting, match: all, conditions: [{ field: ipKind, op: eq, value: hosting }], action: deny }
      responses:
        '200':
          description: The saved ruleset, one version higher.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementRulesetResponse' }
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '403': { $ref: '#/components/responses/ManagementInsufficientScope' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '409': { $ref: '#/components/responses/ManagementConflict' }
        '423': { $ref: '#/components/responses/ManagementAccountReview' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementUncertainOrUnavailable' }
  /v1/management/projects/{projectId}/ruleset/test:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      operationId: managementTestRuleset
      tags: [Management]
      summary: Dry-run a draft ruleset
      description: |
        Evaluates a draft against one stored event and saves nothing. It changes nothing, so it needs only the `read`
        scope, counts against the read rate limit, and still works while the account is under review.
      security:
        - managementKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ManagementRulesetTest' }
            examples:
              test:
                value:
                  eventId: ev_0123456789ABCDEFGHJK
                  ruleset: { enabled: true, rules: [{ name: Block hosting, match: all, conditions: [{ field: ipKind, op: eq, value: hosting }], action: deny }] }
      responses:
        '200':
          description: What the draft would decide for that event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementRulesetTestResult' }
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
        '503': { $ref: '#/components/responses/ManagementUnavailable' }
  /v1/management/projects/{projectId}/events:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      operationId: managementSearchEvents
      tags: [Management]
      summary: Search a project's events
      description: |
        The same filters, paging and limits as `searchEvents` (`GET /v1/events`), for the project in the path.
        Needs the `read` scope.
      security:
        - managementKey: []
      parameters:
        - name: visitorId
          in: query
          schema: { type: string, pattern: '^[A-Za-z0-9_-]{1,80}$' }
        - name: linkedId
          in: query
          schema: { type: string, minLength: 1, maxLength: 256 }
        - name: ip
          in: query
          schema: { type: string }
        - name: suspect
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: platform
          in: query
          schema: { $ref: '#/components/schemas/Platform' }
        - name: bot
          in: query
          description: '`true` matches events where the bot result is `bad` (good bots are not counted), `false` those where it is `not_detected`. An event whose bot result is `good` matches neither `true` nor `false`.'
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: vpn
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: proxy
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: tor
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: incognito
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: tampering
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: virtualMachine
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: rooted
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: hooked
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: emulator
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: mitm
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: locationSpoofed
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: clonedApp
          in: query
          schema: { $ref: '#/components/schemas/BoolParam' }
        - name: bot_result
          in: query
          schema: { $ref: '#/components/schemas/BotResult' }
        - name: bot_category
          in: query
          schema: { $ref: '#/components/schemas/BotCategory' }
        - name: bot_identity
          in: query
          schema: { $ref: '#/components/schemas/BotIdentity' }
        - name: bot_id
          in: query
          schema: { $ref: '#/components/schemas/BotId' }
        - name: start
          in: query
          schema: { type: string }
        - name: end
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: cursor
          in: query
          schema: { type: string }
      responses:
        '200':
          description: One page of events.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementEventPage' }
        '400': { $ref: '#/components/responses/ManagementInvalid' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/management/projects/{projectId}/events/{eventId}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - $ref: '#/components/parameters/EventId'
    get:
      operationId: managementGetEvent
      tags: [Management]
      summary: Get one event
      description: Needs the `read` scope.
      security:
        - managementKey: []
      responses:
        '200':
          description: The event.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementEventResponse' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /v1/management/projects/{projectId}/visitors/{visitorId}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - name: visitorId
        in: path
        required: true
        schema: { type: string, pattern: '^[A-Za-z0-9_-]{1,80}$' }
    get:
      operationId: managementGetVisitor
      tags: [Management]
      summary: Summarise a visitor
      description: Erasing a visitor is not available here. Needs the `read` scope.
      security:
        - managementKey: []
      responses:
        '200':
          description: The visitor summary.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ManagementVisitorResponse' }
        '401': { $ref: '#/components/responses/ManagementUnauthorized' }
        '404': { $ref: '#/components/responses/ManagementNotFound' }
        '429': { $ref: '#/components/responses/ManagementRateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
webhooks:
  event:
    post:
      operationId: event
      tags: [Events]
      summary: A new event, sent to your endpoint
      description: |
        Sent to each HTTPS endpoint you register in the console, once per recorded event (at least once: ignore a
        repeated `FPC-Delivery-Id` or `eventId`). Answer any `2xx` within 3 seconds. A failed delivery is retried up
        to 6 times in total.

        Verify `FPC-Signature` before trusting the body: `t=<unix seconds>,v1=<hex>` (hex in either case) where `v1` is the
        HMAC-SHA256, keyed with the endpoint secret (the whole `whsec_…` string), of `<t>.<raw request body>`.
        Use the raw bytes you received, never re-serialised JSON; compare in constant time; reject a `t` more than
        300 seconds from your clock. The header may carry more than one `v1`; any valid one passes.
      parameters:
        - name: FPC-Signature
          in: header
          required: true
          schema: { type: string, pattern: '^t=[0-9]+,v1=[0-9a-fA-F]+(,v1=[0-9a-fA-F]+)*$' }
        - name: FPC-Event-Id
          in: header
          required: true
          schema: { type: string }
        - name: FPC-Delivery-Id
          in: header
          required: true
          description: '`whd_…`, the same on every retry of this delivery.'
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookEvent' }
            examples:
              event:
                value:
                  v: 1
                  eventId: ev_0123456789ABCDEFGHJK
                  projectId: prj_0123456789ab
                  visitorId: v4_visitor1
                  createdAt: '2026-10-25T09:30:00.000Z'
                  platform: web
                  protocol: 2
                  isNew: false
                  identityConfidence: 92
                  trustScore: 85
                  linkedId: user_123
                  tags: { page: checkout }
                  suspect: false
                  ip: 203.0.113.7
                  ipInfo: { asn: 64500, country: DE, timezone: Europe/Berlin }
                  origin: 'https://shop.example'
                  agentVersion: 0.9.0
                  signals: null
      responses:
        '200':
          description: Received. Any `2xx` counts.
components:
  securitySchemes:
    secretKey:
      type: apiKey
      in: header
      name: X-Secret-Key
      description: The project's secret key (`sk_…`). Backend only.
    publishableKey:
      type: apiKey
      in: header
      name: X-Publishable-Key
      description: The project's publishable key (`pk_…`). Safe in a browser or app.
    managementKey:
      type: http
      scheme: bearer
      description: |
        A management key, format `mk_…` (`mk_` plus 43 URL-safe characters), sent as `Authorization: Bearer mk_…`.
        Created and revoked in the console, shown once. Scope `read` allows `GET` routes and the ruleset dry run;
        scope `write` allows everything on this plane. Any key that is unknown, revoked, expired, malformed or
        belongs to a deleted account is the same `401 invalid_management_key`.
  parameters:
    ProjectId:
      name: projectId
      in: path
      required: true
      schema: { type: string, pattern: '^prj_[0-9a-f]{12}$' }
    WebhookId:
      name: webhookId
      in: path
      required: true
      schema: { type: string, pattern: '^wh_[0-9A-HJKMNP-TV-Z]{16}$' }
    EventId:
      name: eventId
      in: path
      required: true
      schema: { type: string, pattern: '^ev_[0-9A-HJKMNP-TV-Z]{20}$' }
  responses:
    ManagementUnauthorized:
      description: The management key is missing, malformed, unknown, revoked or expired. One answer for all of them.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            invalid:
              value: { ok: false, error: invalid_management_key }
    ManagementInsufficientScope:
      description: The key has the `read` scope and this route needs `write`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            scope:
              value: { ok: false, error: insufficient_scope }
    ManagementAccountReview:
      description: The account is under review. Reads and the ruleset dry run still work; changes are refused.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            review:
              value: { ok: false, error: account_review }
    ManagementRateLimited:
      description: The account's budget is spent (120 reads or 30 changes per minute, counted separately). Nothing was changed; retry after `Retry-After` seconds (the counters are fixed 60-second windows). If the limiter itself cannot be reached the call is refused the same way.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: string, example: '60' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/RateLimitError' }
          examples:
            limited:
              value: { ok: false, error: rate_limited }
    ManagementNotFound:
      description: 'The project, webhook, event or visitor does not exist, or is not this account''s. All of those answer the same, so ids cannot be probed. A path the Management API does not have is also `404 not_found`.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            project:
              value: { ok: false, error: project_not_found }
            other:
              value: { ok: false, error: not_found }
    ManagementInvalid:
      description: A malformed parameter or body. `error` names the problem (`invalid_name`, `invalid_body`, `invalid_ruleset`, ...); `invalid_ruleset` and `invalid_request_filters` add a `path`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            invalid:
              value: { ok: false, error: invalid_name }
            ruleset:
              value: { ok: false, error: invalid_ruleset, path: 'rules[0].action' }
    ManagementConflict:
      description: The request conflicts with the current state (`environment_limit`, `environment_exists`, `not_top_level`, `environments_remaining`, `project_in_use`, `version_conflict`, `limit_reached`).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            conflict:
              value: { ok: false, error: version_conflict, version: 3 }
    ManagementCommitUncertain:
      description: The database did not confirm whether this change was committed. Read the resource to see whether it took effect before repeating the call.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            uncertain:
              value: { ok: false, error: commit_uncertain }
    ManagementUncertainOrUnavailable:
      description: Either a limiter this route depends on could not be reached and nothing was changed (`unavailable`; retry), or the database did not confirm whether the change was committed (`commit_uncertain`; read the resource before repeating).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            unavailable:
              value: { ok: false, error: unavailable }
            uncertain:
              value: { ok: false, error: commit_uncertain }
    ManagementUnavailable:
      description: A limiter this route depends on could not be reached; nothing was changed. Retry.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            unavailable:
              value: { ok: false, error: unavailable }
    Unauthorized:
      description: The key is missing, malformed, unknown, or of the wrong kind for this route.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missing:
              value: { ok: false, error: missing_or_malformed_secret_key }
    InsufficientCredits:
      description: A metered project with an empty balance.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            empty:
              value: { ok: false, error: insufficient_credits }
    ProjectDisabled:
      description: The project is disabled.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            disabled:
              value: { ok: false, error: project_disabled }
    RateLimited:
      description: Too many requests from this address (the answer may also come before authentication).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/RateLimitError' }
          examples:
            limited:
              value: { ok: false, error: rate_limited }
    RateLimitedReason:
      description: "Rate limited, by the route's own limit (body `{ ok: false, reason: rate_limited }`) or by the per-IP limit that runs before authentication (body `{ ok: false, error: rate_limited }`). Clients must accept both bodies."
      content:
        application/json:
          schema:
            oneOf:
              - $ref: '#/components/schemas/ReasonError'
              - $ref: '#/components/schemas/RateLimitError'
          examples:
            limited:
              value: { ok: false, reason: rate_limited }
            preAuth:
              value: { ok: false, error: rate_limited }
    CommitUncertain:
      description: The database did not confirm whether the decision and its charge were committed. The call may have been charged; it is not replayed automatically.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            uncertain:
              value: { ok: false, error: commit_uncertain }
    InvalidRequest:
      description: An unknown, repeated or malformed parameter or body field.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            invalid:
              value: { ok: false, error: invalid_request }
    NotFound:
      description: 'Malformed, missing, or another project''s id: all answer the same, so ids cannot be probed.'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missing:
              value: { ok: false, error: not_found }
    InternalError:
      description: Fail-closed server error (never an empty result).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            internal:
              value: { ok: false, error: internal_error }
  schemas:
    ManagementAccount:
      type: object
      required: [id, status, balance, grantedTotal, consumedTotal, nextExpiryAt, createdAt]
      properties:
        id: { type: string }
        status: { type: string, description: '`active`, or `review` while the account is under review. New values may be added.' }
        balance: { type: integer, description: Credits left. }
        grantedTotal: { type: integer }
        consumedTotal: { type: integer }
        nextExpiryAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }
    ManagementLot:
      type: object
      required: [id, amount, remaining, grantedAt, expiresAt, expiredAt]
      properties:
        id: { type: integer }
        amount: { type: integer }
        remaining: { type: integer }
        grantedAt: { type: string, format: date-time }
        expiresAt: { type: string, format: date-time }
        expiredAt: { type: [string, 'null'], format: date-time }
    ManagementAccountResponse:
      type: object
      required: [ok, account, lots]
      properties:
        ok: { const: true }
        account: { $ref: '#/components/schemas/ManagementAccount' }
        lots:
          type: array
          items: { $ref: '#/components/schemas/ManagementLot' }
    ManagementUsageResponse:
      type: object
      required: [ok, rows]
      properties:
        ok: { const: true }
        rows:
          type: array
          items:
            type: object
            required: [day, projectId, endpoint, n]
            properties:
              day: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
              projectId: { type: string }
              endpoint: { type: string }
              n: { type: integer }
    ManagementAuditItem:
      type: object
      required: [id, at, actor, action, target, detail]
      properties:
        id: { type: integer }
        at: { type: string, format: date-time }
        actor:
          type: object
          required: [type, id, label]
          properties:
            type: { type: string, description: '`user`, `management_key` or `system`. New values may be added.' }
            id: { type: string }
            label: { type: [string, 'null'] }
        action: { type: string, description: A dotted name such as `project.update`. New actions may be added. }
        target:
          oneOf:
            - type: 'null'
            - type: object
              required: [type, id]
              properties:
                type: { type: string }
                id: { type: string }
        detail: { type: object, description: Small, action-specific facts. Never a key or a secret. }
    ManagementAuditPage:
      type: object
      required: [ok, items, nextCursor]
      properties:
        ok: { const: true }
        items:
          type: array
          items: { $ref: '#/components/schemas/ManagementAuditItem' }
        nextCursor: { type: [string, 'null'], description: Pass as `cursor`; null on the last page. }
    ManagementProject:
      type: object
      required: [id, sealKey, name, disabled, allowedOrigins, hasTurnstileSecret, nativeConfig, policy, claimGroupId, eventRetentionDays, requestFilters, ruleset, createdAt, environment, environmentOf]
      properties:
        id: { type: string, pattern: '^prj_[0-9a-f]{12}$' }
        sealKey: { type: string, description: "Read-only here. The browser agent's upload key; it ships in the customer's page. Managed in the console." }
        name: { type: string }
        disabled: { type: boolean }
        allowedOrigins:
          type: array
          items: { type: string }
        hasTurnstileSecret: { type: boolean, description: The secret itself is never returned. }
        nativeConfig: { type: [object, 'null'], description: App credentials with all key material left out. }
        policy: { type: object, description: The project's policy settings. New settings may be added. }
        claimGroupId: { type: [string, 'null'], description: Read-only here; set in the console. A PATCH containing it is refused with `invalid_field`. }
        eventRetentionDays: { type: integer, minimum: 7, maximum: 90 }
        requestFilters: { type: object, description: 'Request filters: `ipDeny`, `headerRules`, `blockCrawlers`. `blockCrawlers` refuses every directory bot by User-Agent, including search engines and synthetic monitors (Datadog Synthetics, Checkly, UptimeRobot, Lighthouse, GTmetrix); to allow some of them use rules (an allow rule on `botId`) with the filter off, since the filter runs before rules.' }
        ruleset: { $ref: '#/components/schemas/ManagementRuleset' }
        createdAt: { type: string, format: date-time }
        environment: { type: string, description: '`production` for a top-level project, otherwise the environment name.' }
        environmentOf: { type: [string, 'null'], description: The parent project's id; null for a top-level project. }
    ManagementProjectResponse:
      type: object
      required: [ok, project]
      properties:
        ok: { const: true }
        project: { $ref: '#/components/schemas/ManagementProject' }
    ManagementProjectList:
      type: object
      required: [ok, projects]
      properties:
        ok: { const: true }
        projects:
          type: array
          items: { $ref: '#/components/schemas/ManagementProject' }
    ManagementProjectCreate:
      type: object
      required: [name]
      properties:
        name: { type: string, minLength: 1, maxLength: 80 }
        allowedOrigins:
          type: array
          maxItems: 20
          items: { type: string }
    ManagementProjectCreated:
      type: object
      required: [ok, project, publishableKey, secretKey]
      properties:
        ok: { const: true }
        project: { $ref: '#/components/schemas/ManagementProject' }
        publishableKey: { type: string, description: Shown once. }
        secretKey: { type: string, description: Shown once. }
    ManagementProjectPatch:
      type: object
      description: At least one field. Unlisted fields are ignored.
      properties:
        name: { type: string, minLength: 1, maxLength: 80 }
        allowedOrigins:
          type: array
          maxItems: 20
          items: { type: string }
        disabled: { type: boolean }
        eventRetentionDays: { type: integer, minimum: 7, maximum: 90 }
        turnstileSecret: { type: [string, 'null'], description: Write-only; null removes it. }
        requestFilters: { type: [object, 'null'], description: 'Null resets to no filters.' }
        policy: { type: object, description: The settings to change; the rest keep their values. }
        nativeConfig: { type: [object, 'null'], description: Write-only app credentials. }
    ManagementProjectDeleted:
      type: object
      required: [ok, deleted]
      properties:
        ok: { const: true }
        deleted: { type: string, description: The deleted project's id. }
    ManagementRotate:
      type: object
      required: [key]
      properties:
        key: { type: string, enum: [secret, publishable] }
    ManagementRotated:
      type: object
      required: [ok, key]
      properties:
        ok: { const: true }
        key: { type: string, description: The new key (`sk_…` or `pk_…`). Shown once. }
    ManagementEnvironmentCreate:
      type: object
      required: [name]
      properties:
        name: { type: string, pattern: '^[a-z][a-z0-9-]{0,19}$', description: 'Lower case; `production` is the top-level project and cannot be used.' }
    ManagementEnvironmentCreated:
      type: object
      required: [ok, project, publishableKey, secretKey, skipped]
      properties:
        ok: { const: true }
        project: { $ref: '#/components/schemas/ManagementProject' }
        publishableKey: { type: string, description: Shown once. }
        secretKey: { type: string, description: Shown once. }
        skipped:
          type: array
          description: Settings that could not be copied and were left empty. New values may be added.
          items: { type: string }
    ManagementCopySettings:
      type: object
      required: [from]
      properties:
        from: { type: string, pattern: '^prj_[0-9a-f]{12}$', description: Another project of the same family. }
    ManagementSettingsCopied:
      type: object
      required: [ok, project, skipped]
      properties:
        ok: { const: true }
        project: { $ref: '#/components/schemas/ManagementProject' }
        skipped:
          type: array
          items: { type: string }
    ManagementWebhook:
      type: object
      required: [webhookId, url, enabled, disabledReason, createdAt]
      properties:
        webhookId: { type: string, pattern: '^wh_[0-9A-HJKMNP-TV-Z]{16}$' }
        url: { type: string }
        enabled: { type: boolean }
        disabledReason: { type: [string, 'null'] }
        createdAt: { type: string, format: date-time }
    ManagementWebhookList:
      type: object
      required: [ok, webhooks]
      properties:
        ok: { const: true }
        webhooks:
          type: array
          items: { $ref: '#/components/schemas/ManagementWebhook' }
    ManagementWebhookCreate:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url: { type: string, format: uri }
    ManagementWebhookCreated:
      type: object
      required: [ok, webhook, secret]
      properties:
        ok: { const: true }
        webhook: { $ref: '#/components/schemas/ManagementWebhook' }
        secret: { type: string, description: The signing secret. Shown once. }
    ManagementWebhookPatch:
      type: object
      required: [enabled]
      additionalProperties: false
      properties:
        enabled: { type: boolean }
    ManagementWebhookResponse:
      type: object
      required: [ok, webhook]
      properties:
        ok: { const: true }
        webhook: { $ref: '#/components/schemas/ManagementWebhook' }
    ManagementWebhookDeleted:
      type: object
      required: [ok, deleted]
      properties:
        ok: { const: true }
        deleted: { type: string }
    ManagementWebhookRotated:
      type: object
      required: [ok, secret]
      properties:
        ok: { const: true }
        secret: { type: string, description: The new signing secret. Shown once. }
    ManagementCondition:
      type: object
      required: [field, op, value]
      properties:
        field: { type: string }
        op: { type: string, enum: [eq, neq, gt, gte, lt, lte, in, not_in, includes, not_includes] }
        value:
          oneOf:
            - { type: boolean }
            - { type: number }
            - { type: string }
            - { type: array, items: { type: string } }
    ManagementRuleInput:
      type: object
      required: [name, match, conditions, action]
      properties:
        id: { type: string, description: Keeps the id of an existing rule; omit for a new one. }
        name: { type: string, minLength: 1, maxLength: 80 }
        enabled: { type: boolean, default: true }
        match: { type: string, enum: [all, any] }
        conditions:
          type: array
          minItems: 1
          maxItems: 10
          items: { $ref: '#/components/schemas/ManagementCondition' }
        action: { type: string, enum: [allow, human_check, deny] }
    ManagementRule:
      allOf:
        - $ref: '#/components/schemas/ManagementRuleInput'
        - type: object
          required: [id, enabled]
    ManagementRuleset:
      type: object
      required: [version, enabled, rules]
      properties:
        version: { type: integer, minimum: 0 }
        enabled: { type: boolean }
        rules:
          type: array
          maxItems: 50
          items: { $ref: '#/components/schemas/ManagementRule' }
    ManagementRulesetResponse:
      type: object
      required: [ok, ruleset]
      properties:
        ok: { const: true }
        ruleset: { $ref: '#/components/schemas/ManagementRuleset' }
    ManagementRulesetPut:
      type: object
      required: [ifVersion, enabled, rules]
      properties:
        ifVersion: { type: integer, minimum: 0, description: The `version` this edit is based on. }
        enabled: { type: boolean }
        rules:
          type: array
          maxItems: 50
          items: { $ref: '#/components/schemas/ManagementRuleInput' }
    ManagementRulesetTest:
      type: object
      required: [eventId, ruleset]
      properties:
        eventId: { type: string, pattern: '^ev_[0-9A-HJKMNP-TV-Z]{20}$' }
        ruleset:
          type: object
          required: [enabled, rules]
          properties:
            enabled: { type: boolean }
            rules:
              type: array
              items: { $ref: '#/components/schemas/ManagementRuleInput' }
    ManagementRulesetTestResult:
      type: object
      required: [ok, type, trace]
      properties:
        ok: { const: true }
        type: { type: string, description: '`allow`, `human_check`, `deny`, or `none` when no rule matched. New values may be added.' }
        ruleId: { type: string }
        ruleName: { type: string }
        trace: { type: array, items: {}, description: One entry per rule evaluated. }
    ManagementEventPage:
      type: object
      required: [ok, events, next]
      properties:
        ok: { const: true }
        events:
          type: array
          items: { $ref: '#/components/schemas/Event' }
        next: { type: [string, 'null'], description: 'Pass as `cursor`. Can be non-null on an exactly full last page: loop until it is null.' }
    ManagementEventResponse:
      type: object
      required: [ok, event]
      properties:
        ok: { const: true }
        event: { $ref: '#/components/schemas/Event' }
    ManagementVisitorResponse:
      type: object
      required: [ok, visitor]
      properties:
        ok: { const: true }
        visitor: { $ref: '#/components/schemas/Visitor' }
    Error:
      type: object
      description: The common failure shape. `error` is a stable code.
      required: [ok, error]
      properties:
        ok: { const: false }
        error: { type: string }
      example: { ok: false, error: not_found }
    RateLimitError:
      type: object
      description: The per-IP limit that runs before authentication answers this on every route, whatever shape the route's own errors have.
      required: [ok, error]
      properties:
        ok: { const: false }
        error: { const: rate_limited }
      example: { ok: false, error: rate_limited }
    ReasonError:
      type: object
      description: A failure that names its code in `reason`.
      required: [ok, reason]
      properties:
        ok: { const: false }
        reason: { type: string }
      example: { ok: false, reason: rate_limited }
    Platform:
      type: string
      enum: [web, android, ios, macos, cli]
    BoolParam:
      type: string
      enum: ['true', 'false']
    Health:
      type: object
      required: [ok, version, env, commit]
      properties:
        ok: { const: true }
        version: { type: string }
        env: { type: string }
        commit: { type: [string, 'null'] }
        maintenance: { const: true, description: Present only while the server is in maintenance. }
    Status:
      type: object
      required: [ok, version, maintenance, checks, checkedAt]
      properties:
        ok: { type: boolean }
        version: { type: string }
        maintenance: { type: boolean }
        checks:
          type: object
          required: [api, database, cache]
          properties:
            api: { const: true }
            database: { type: boolean }
            cache: { type: boolean }
        checkedAt: { type: string, format: date-time }
    IntentRequest:
      type: object
      required: [purpose]
      properties:
        purpose:
          type: string
          description: '`claim:<kind>` (kind `trial` unless the project configures others), `quota:<name>` or `subjects`.'
          pattern: '^(subjects|claim:[a-z0-9][a-z0-9_-]{0,39}|quota:[a-z0-9][a-z0-9_-]{0,39})$'
        subject:
          type: [string, 'null']
          description: Optional, 1 to 200 characters, no control characters. When set, the decision call must pass the same `subject`.
          minLength: 1
          maxLength: 200
        clientIp:
          type: string
          description: The end user's IP, for rate limiting. Take it only from your own edge.
    IntentResponse:
      type: object
      required: [ok, intentToken, expiresIn]
      properties:
        ok: { const: true }
        intentToken: { type: string, description: Opaque. }
        expiresIn: { type: integer, description: Seconds. }
    ChallengeResponse:
      type: object
      required: [ok, protocolVersion, challenge, expiresIn]
      properties:
        ok: { const: true }
        protocolVersion: { const: 2 }
        challenge: { type: string, description: Opaque. }
        expiresIn: { type: integer, description: Seconds. }
    Component:
      type: object
      required: [key, value]
      properties:
        key: { type: string, pattern: '^[A-Za-z0-9._-]{1,64}$' }
        value: { type: string }
        hash: { type: string, pattern: '^[0-9a-f]{64}$', description: Web agent only. }
    NativeProof:
      description: A platform proof, bound to the request by the SDK.
      oneOf:
        - type: object
          required: [type, token]
          properties:
            type: { const: play_integrity }
            token: { type: string }
        - type: object
          required: [type, keyId]
          properties:
            type: { const: app_attest }
            keyId: { type: string }
            attestation: { type: [string, 'null'] }
            assertion: { type: [string, 'null'] }
    IdentifyRequest:
      type: object
      required: [protocolVersion, platform, intentToken, challenge, agentVersion, components]
      properties:
        protocolVersion: { const: 2 }
        platform: { $ref: '#/components/schemas/Platform' }
        intentToken: { type: string }
        challenge: { type: string }
        agentVersion: { type: string, maxLength: 64 }
        components:
          type: array
          items: { $ref: '#/components/schemas/Component' }
        pow: { type: string, description: 'Proof-of-work solution, when the challenge asked for one.' }
        proof: { $ref: '#/components/schemas/NativeProof' }
        deviceCheckToken: { type: string }
        linkedId:
          type: [string, 'null']
          description: Your own id for this visit (1 to 256 characters, no control characters). Not covered by any platform proof.
          minLength: 1
          maxLength: 256
        tags:
          type:
            - object
            - 'null'
          description: A JSON object of at most 4096 bytes, nested at most 3 levels. Not covered by any platform proof.
    IdentifyResponse:
      type: object
      required: [ok, protocolVersion, attestation, expiresIn, eventId]
      properties:
        ok: { const: true }
        protocolVersion: { const: 2 }
        attestation: { type: string, minLength: 1, description: Opaque. Send it unchanged to your backend. }
        expiresIn: { type: integer, description: Seconds. }
        eventId: { type: string, pattern: '^ev_[0-9A-HJKMNP-TV-Z]{20}$' }
        proofStatus:
          type: string
          enum: [none, unconfigured, wrong_platform, unavailable, checked, unknown_key]
          description: Native platforms only. `unknown_key` means re-attest the App Attest key and retry once.
        sealedResult:
          type: string
          description: |
            Present only when the project has an active sealing key, and never on a refusal. Standard base64 (with
            padding) of `"fps1" | keyId(4) | nonce(12) | AES-256-GCM ciphertext and 16-byte tag`; keyId is the first
            4 bytes of SHA-256(key) and the first 8 bytes are the AAD. Decrypt it in your backend with the project's
            sealing key (keep the previous key too during a rotation). The plaintext is identity-only JSON:
            `version` (1), `eventId`, `visitorId`, `projectId`, `linkedId` (or null), `tags` (or null), `timestamp`
            (Unix ms), `platform` and `proofStatus` (always `none` on web). There is no IP address, no signals, no
            score and no decision in it. Full format and a sample: docs/INTEGRATION.md, "Sealed results".

            A sealed result is proof of origin, not a grant: it shows FPChecker produced this event for a project,
            nothing more. It does not expire and anyone who holds the blob can replay it. Before you trust one,
            check that `projectId` is yours, reject a `timestamp` older than a short window (5 minutes is a good
            default; make it configurable), and treat `eventId` as single-use or require `linkedId` to match the
            current session or user. Never grant a trial, coupon or credit from it: use `POST /v1/eligibility`
            with the attestation for that.
      description: |
        Projects whose administrator enabled score details also get `visitorId`, `trustScore`, `identityConfidence`,
        `isNew` and rule diagnostics here. Do not branch on them.
    IdentifyRefusal:
      type: object
      required: [ok, protocolVersion, reason]
      properties:
        ok: { const: false }
        protocolVersion: { const: 2 }
        reason:
          type: string
          description: For example `bad_request`, `bad_intent`, `bad_nonce`, `nonce_reused`, `components_too_large`, `identify_daily_limit`, `project_write_budget`, `internal_error`.
        error: { type: string, description: Set with `reason` for `invalid_linked_id` and `invalid_tags`. }
    EligibilityRequest:
      type: object
      required: [attestation]
      properties:
        attestation: { type: string, description: The `attestation` from identify. }
        subject: { type: string, description: Must equal the intent's subject when the intent named one. }
        email: { type: string }
        claim: { type: boolean, description: Record one claim when the decision is allow. }
        idempotent: { type: boolean, description: 'With `claim`, answer allow again (`replayed: true`) when the same identity claimed within 24 hours.' }
        kind: { type: string, description: Claim kind; default `trial`. }
        turnstileToken: { type: string }
        clientIp: { type: string }
    Action:
      type: string
      enum: [allow, turnstile, pow, deny]
    EligibilityResponse:
      type: object
      required: [ok, visitorId, confidence, identityConfidence, trustScore, isNew, eligible, action, reason]
      properties:
        ok: { const: true }
        visitorId: { type: string }
        confidence: { type: number }
        identityConfidence: { type: number }
        trustScore: { type: number }
        isNew: { type: boolean }
        eligible: { type: boolean, description: Grant only when true. }
        action: { $ref: '#/components/schemas/Action' }
        reason: { type: string, description: 'See "Handle the result" in docs/INTEGRATION.md. Includes `rule_deny` (a deny rule matched; `ruleId` says which) and `rule_allow` (an allow rule let a first claim through the first-claim and trust gates; in a shared device class it skips only the datacenter check and the human check still runs). New values may be added; treat an unknown value as a generic refusal.' }
        powDifficulty: { type: integer, description: 'With action `pow` (or `turnstile` in `either` mode), the zero bits a proof needs.' }
        replayed: { type: boolean }
        claimId: { type: string, description: 'Present on an allow with `claim: true`.' }
        ruleId: { type: string, description: 'Present only when a rule of the project''s ruleset decided the answer (`rule_deny`, or a `human_check` rule) or a matched `allow` rule applied (reason `rule_allow`). Never present otherwise.' }
    EligibilityRefusal:
      type: object
      required: [ok, eligible, action, reason]
      properties:
        ok: { const: false }
        eligible: { const: false }
        action: { const: deny }
        reason:
          type: string
          description: 'The attestation reasons are HTTP 200 (HTTP 400 only when the attestation is missing). New values may be added; treat an unknown value as a generic refusal.'
          enum: [bad_attestation, attestation_purpose, attestation_subject, intent_required, rate_limited, project_write_budget, internal_error]
    ReleaseRequest:
      type: object
      required: [claimId]
      properties:
        claimId: { type: string, pattern: '^[1-9][0-9]{0,14}$' }
    ReleaseResponse:
      type: object
      required: [ok, claimId, released, reason]
      properties:
        ok: { const: true }
        claimId: { type: string }
        released: { type: boolean }
        reason: { type: string, description: 'New values may be added; treat an unknown value as a generic refusal.', enum: [released, already_released, claim_not_found, release_window_passed, release_limit] }
    ReleaseRefusal:
      type: object
      required: [ok, released, reason]
      properties:
        ok: { const: false }
        released: { const: false }
        reason: { type: string }
    QuotaRequest:
      type: object
      required: [quota]
      properties:
        attestation: { type: string, description: Required unless `release` is true. }
        subject: { type: string }
        quota: { type: string, pattern: '^[a-z0-9][a-z0-9_-]{0,39}$' }
        consume: { type: boolean }
        idempotencyKey: { type: string, pattern: '^[A-Za-z0-9_.:-]{1,64}$' }
        release: { type: boolean, description: Give back the use recorded under `idempotencyKey`. Not combinable with `consume`. }
        turnstileToken: { type: string }
        clientIp: { type: string }
    QuotaResponse:
      type: object
      required: [ok, visitorId, identityConfidence, trustScore, quota, allowed, action, reason]
      properties:
        ok: { const: true }
        visitorId: { type: string }
        identityConfidence: { type: number }
        trustScore: { type: number }
        quota: { type: string }
        allowed: { type: boolean }
        action: { $ref: '#/components/schemas/Action' }
        reason: { type: string, description: 'Includes `rule_deny` when a deny rule matched. New values may be added; treat an unknown value as a generic refusal.' }
        ruleId: { type: string, description: 'Present with reason `rule_deny`: the rule that denied.' }
        powDifficulty: { type: integer }
        limit: { type: integer }
        used: { type: integer, description: 'Uses in the current window, including this one when consumed.' }
        remaining: { type: integer }
        windowSeconds: { type: integer }
        retryAt: { type: integer, description: Unix milliseconds when a denied quota frees one use. }
        replayed: { type: boolean }
    QuotaReleaseResponse:
      type: object
      required: [ok, quota, released]
      properties:
        ok: { const: true }
        quota: { type: string }
        released: { type: boolean }
    QuotaRefusal:
      type: object
      required: [ok, allowed, action, reason]
      properties:
        ok: { const: false }
        allowed: { const: false }
        action: { const: deny }
        reason:
          type: string
          description: 'The attestation reasons are HTTP 200 (HTTP 400 only when the attestation is missing). New values may be added; treat an unknown value as a generic refusal.'
          enum: [bad_attestation, attestation_purpose, attestation_subject, intent_required, bad_request, rate_limited, project_write_budget, internal_error]
    SubjectRequest:
      type: object
      required: [subject, attestation]
      properties:
        subject: { type: string, minLength: 1, maxLength: 200 }
        attestation: { type: string }
    BindResponse:
      type: object
      required: [ok, visitorId, bound]
      properties:
        ok: { const: true }
        visitorId: { type: string }
        bound: { const: true }
    Suggestion:
      type: string
      enum: [allow, review, deny]
    RelationResponse:
      type: object
      required: [ok, visitorId, sameDevice, sameBrowserProfile, sameMachine, sameNetwork, sharedDeviceClass, subjectDevices, subjectBound, subjectSaturated, suggestion]
      properties:
        ok: { const: true }
        visitorId: { type: string }
        sameDevice: { type: boolean }
        sameBrowserProfile: { type: boolean }
        sameMachine: { type: boolean, description: Coarse; one model of machine can share it. }
        sameNetwork: { type: boolean, description: 'Weak; flatmates, colleagues and carrier NAT share one.' }
        sharedDeviceClass: { type: boolean }
        subjectDevices: { type: integer, description: Bound devices that were compared. }
        subjectBound: { type: boolean, description: 'False when the subject has no binding: nothing was compared.' }
        subjectSaturated: { type: boolean }
        suggestion: { $ref: '#/components/schemas/Suggestion' }
    SubjectRefusal:
      type: object
      required: [ok, reason]
      properties:
        ok: { const: false }
        reason:
          type: string
          description: 'The attestation reasons are HTTP 200 (HTTP 400 only when the attestation is missing). New values may be added; treat an unknown value as a generic refusal.'
          enum: [bad_attestation, attestation_purpose, attestation_subject, intent_required, bad_request, bad_subject, rate_limited, project_write_budget, internal_error]
        suggestion: { const: review, description: Only on a relation refusal. }
    FeedbackRequest:
      type: object
      required: [label]
      properties:
        visitorId: { type: string, description: Exactly one of `visitorId` and `claimId`. }
        claimId: { type: string, pattern: '^[1-9][0-9]{0,14}$' }
        label: { type: string, enum: [abuse, legit] }
        reason: { type: string, pattern: '^[a-z0-9_]{1,64}$' }
    FeedbackResponse:
      type: object
      required: [ok, recorded]
      properties:
        ok: { const: true }
        recorded: { const: true }
    FeedbackRefusal:
      type: object
      required: [ok, recorded, reason]
      properties:
        ok: { const: false }
        recorded: { const: false }
        reason: { type: string }
    ClaimFlag:
      type: object
      required: [flagId, claimId, relatedClaimId, kind, reason, flaggedAt]
      properties:
        flagId: { type: string }
        claimId: { type: string }
        relatedClaimId: { type: string }
        kind: { type: string }
        reason: { type: string }
        flaggedAt: { type: integer, description: Unix milliseconds. }
    ClaimFlagsResponse:
      type: object
      required: [ok, flags, next]
      properties:
        ok: { const: true }
        flags:
          type: array
          items: { $ref: '#/components/schemas/ClaimFlag' }
        next: { type: [string, 'null'], description: 'Pass as `after`. Non-null whenever the page is exactly full (the following page may then be empty); loop until it is null.' }
    Window:
      type: object
      required: ['5m', '1h', '24h']
      properties:
        '5m': { type: integer }
        '1h': { type: integer }
        '24h': { type: integer }
    SignalsCommon:
      type: object
      description: The signal fields that are the same in every signals version. See `Signals`.
      required: [ip, vpn, proxy, incognito, tampering, privacyBrowser, virtualMachine, devtools, velocity, mobile]
      properties:
        ip:
          type: object
          required: [country, city, timezone, asn, asName, datacenter, tor, relay, blocklist]
          properties:
            country: { type: [string, 'null'] }
            city: { type: [string, 'null'] }
            timezone: { type: [string, 'null'] }
            asn: { type: [integer, 'null'] }
            asName: { type: [string, 'null'], description: 'Where you show it, add "IP address data powered by IPinfo".' }
            datacenter: { type: [boolean, 'null'] }
            tor: { type: [boolean, 'null'] }
            relay: { type: [boolean, 'null'] }
            blocklist:
              type: object
              required: [result, sources]
              properties:
                result: { type: [boolean, 'null'] }
                sources:
                  type: array
                  items: { type: string }
        vpn:
          type: object
          required: [result, methods]
          properties:
            result: { type: [boolean, 'null'] }
            methods:
              type: array
              items: { type: string, enum: [timezone_mismatch, ip_range, relay, os_vpn], description: 'New values may be added; ignore a value you do not know. `os_vpn` is the device reporting an active VPN (android, ios).' }
        proxy:
          type: object
          required: [result, type]
          properties:
            result: { type: [boolean, 'null'] }
            type: { enum: [datacenter, null] }
        incognito: { type: [boolean, 'null'], description: '`true` or `false` in Chrome and Firefox on desktop; `null` in every other browser and in native apps. Not part of the trust score.' }
        tampering:
          type: object
          required: [result, antiDetectBrowser]
          properties:
            result: { type: [boolean, 'null'] }
            antiDetectBrowser: { type: [boolean, 'null'] }
        privacyBrowser:
          type: object
          required: [result, name]
          properties:
            result: { type: [boolean, 'null'] }
            name: { enum: [brave, firefox_rfp, tor_browser, null] }
        virtualMachine: { type: [boolean, 'null'] }
        devtools: { type: [boolean, 'null'], description: Reserved; currently always null. }
        velocity:
          description: Approximate counts including this event; null when the database was slow or busy.
          oneOf:
            - type: 'null'
            - type: object
              required: [distinctIp, distinctCountry, distinctLinkedId, events]
              properties:
                distinctIp: { $ref: '#/components/schemas/Window' }
                distinctCountry: { $ref: '#/components/schemas/Window' }
                distinctLinkedId: { $ref: '#/components/schemas/Window' }
                events: { $ref: '#/components/schemas/Window' }
        mobile:
          description: android, ios and macos only; null for web.
          oneOf:
            - type: 'null'
            - type: object
              required: [emulator, attested, integrity]
              properties:
                emulator: { type: [boolean, 'null'] }
                attested: { type: [boolean, 'null'] }
                integrity: { enum: [strong, basic, failed, null] }
                rooted: { type: [boolean, 'null'], description: &tri 'Three states: `true` = detected, `false` = checked and nothing found, `null` = not checked (old SDK, probes switched off, or not supported on this platform). Not a guarantee either way.' }
                hooked: { type: [boolean, 'null'], description: *tri }
                debugger: { type: [boolean, 'null'], description: *tri }
                developerMode: { type: [boolean, 'null'], description: *tri }
                mitm: { type: [boolean, 'null'], description: *tri }
                vpn: { type: [boolean, 'null'], description: *tri }
                locationSpoofed: { type: [boolean, 'null'], description: *tri }
                clonedApp: { type: [boolean, 'null'], description: *tri }
                factoryResetAt: { type: [string, 'null'], pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$', description: 'UTC date of the last device setup (android only); `null` when unknown.' }
    Signals:
      description: |
        What the server learned about the visit. **Unknown is `null`, never `false`**: `false` means the check ran
        and found nothing. Evidence to weigh, not verdicts. `version` is `2` for every event stored by server
        0.48.0 or later; older events are read back as version 1 (no `good` result and no `bot.info`).
      oneOf:
        - $ref: '#/components/schemas/SignalsV1'
        - $ref: '#/components/schemas/SignalsV2'
    SignalsV1:
      description: Signals of an event stored before server 0.48.0.
      allOf:
        - $ref: '#/components/schemas/SignalsCommon'
        - type: object
          required: [version, bot]
          properties:
            version: { const: 1 }
            bot:
              type: object
              required: [result, types]
              properties:
                result: { enum: [not_detected, bad, null] }
                types:
                  type: array
                  items: { $ref: '#/components/schemas/BotType' }
    SignalsV2:
      description: Signals of an event stored by server 0.48.0 or later.
      allOf:
        - $ref: '#/components/schemas/SignalsCommon'
        - type: object
          required: [version, bot]
          properties:
            version: { const: 2 }
            bot:
              type: object
              required: [result, types, info]
              properties:
                result:
                  enum: [not_detected, good, bad, null]
                  description: '`good` is a directory bot whose identity is `verified` or `signed`. A directory bot that is `unknown` or `spoofed` is `bad`. New values may be added; treat an unknown value as `bad`.'
                types:
                  type: array
                  items: { $ref: '#/components/schemas/BotType' }
                info:
                  description: Who the bot is, when the request named an entry of the Bot directory; otherwise `null`.
                  oneOf:
                    - type: 'null'
                    - $ref: '#/components/schemas/BotInfo'
    BotType:
      type: string
      enum: [webdriver, headless, playwright, selenium, inhuman_timing, inconsistent_environment]
      description: New values may be added; ignore a value you do not know.
    BotHeaderType:
      type: string
      enum: [automation_tool, headless, missing_headers]
      description: What the headers of a single request give away. New values may be added; ignore a value you do not know.
    BotResult:
      type: string
      enum: [not_detected, good, bad]
    BotCategory:
      type: string
      enum: [search_crawler, ai_crawler, ai_search, ai_assistant, ai_agent, seo, monitoring, social_preview, advertising, aggregator, scraping, other]
      description: New values may be added; treat an unknown value as `other`.
    BotIdentity:
      type: string
      enum: [verified, signed, unknown, spoofed]
      description: '`verified` = the address matches the operator''s published ranges or reverse DNS; `signed` = a valid signature from a signer listed in the Bot directory; `unknown` = could not be confirmed; `spoofed` = claims to be the bot, but the address or signature says otherwise.'
    BotId:
      type: string
      description: The id of an entry of the Bot directory. New ids are added as the directory grows.
      enum:
        - googlebot
        - google-storebot
        - google-inspection-tool
        - googleother
        - google-cloudvertexbot
        - bingbot
        - bingpreview
        - adidxbot
        - yahoo-slurp
        - duckduckbot
        - baiduspider
        - yandexbot
        - yandex-images
        - applebot
        - sogou-spider
        - naver-yeti
        - daumoa
        - seznambot
        - mojeekbot
        - qwantify
        - coccocbot
        - petalbot
        - mail-ru-bot
        - 360spider
        - bravebot
        - adsbot-google
        - mediapartners-google
        - apis-google
        - google-read-aloud
        - google-site-verifier
        - feedfetcher-google
        - google-notebooklm
        - chrome-lighthouse
        - gptbot
        - oai-searchbot
        - chatgpt-user
        - chatgpt-agent
        - oai-adsbot
        - claudebot
        - claude-searchbot
        - claude-user
        - anthropic-ai
        - perplexitybot
        - perplexity-user
        - duckassistbot
        - ccbot
        - bytespider
        - tiktokspider
        - amazonbot
        - amzn-searchbot
        - amzn-user
        - amazon-buy-for-me
        - amazon-ad-bot
        - meta-externalagent
        - meta-externalfetcher
        - meta-webindexer
        - facebookbot
        - cohere-ai
        - cohere-training-data-crawler
        - diffbot
        - ai2bot
        - mistralai-user
        - timpibot
        - imagesiftbot
        - icc-crawler
        - sbintuitionsbot
        - cotoyogi
        - factset-spyderbot
        - velenpublicwebcrawler
        - youbot
        - phindbot
        - andibot
        - kimi-searchbot
        - exasearchbot
        - semrushbot
        - semrushbot-swa
        - semrushbot-ocob
        - siteauditbot
        - ahrefsbot
        - ahrefssiteaudit
        - mj12bot
        - dotbot
        - rogerbot
        - screaming-frog
        - dataforseobot
        - blexbot
        - seokicks
        - serpstatbot
        - sistrix
        - megaindex
        - barkrowler
        - contentking
        - uptimerobot
        - pingdom
        - statuscake
        - site24x7
        - hetrixtools
        - uptime-kuma
        - gtmetrix
        - datadog-synthetics
        - checkly
        - siteimprove
        - better-stack
        - facebookexternalhit
        - twitterbot
        - linkedinbot
        - slackbot
        - discordbot
        - telegrambot
        - whatsapp
        - pinterestbot
        - redditbot
        - skypeuripreview
        - embedly
        - iframely
        - mastodon
        - vkshare
        - quora-link-preview
        - bitlybot
        - feedly
        - flipboard
        - inoreader
        - newsblur
        - feedbin
        - theoldreader
        - feedspot
        - bazqux
        - miniflux
        - freshrss
        - omgili
        - yandex-direct
        - criteobot
        - scrapy
        - httrack
        - img2dataset
        - crawl4ai
        - archive-org-bot
        - heritrix
        - w3c-validator
        - netcraft
        - censys
        - google-favicon
    BotInfo:
      type: object
      description: One entry of the Bot directory and how well the request matched it.
      required: [id, name, provider, category, identity]
      properties:
        id: { type: string, description: 'Stable id, for example `googlebot`; the values of `bot_id`.' }
        name: { type: string }
        provider: { type: string }
        category: { $ref: '#/components/schemas/BotCategory' }
        identity: { $ref: '#/components/schemas/BotIdentity' }
    BotCheckRequest:
      type: object
      required: [method, url, ip, headers]
      properties:
        method: { type: string, enum: [GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS], description: Case-insensitive. }
        url: { type: string, maxLength: 2048, description: 'An absolute http or https URL, as the visitor requested it (original host and scheme).' }
        ip: { type: string, description: The client address of the request (IPv4 or IPv6, no zone id). }
        headers:
          type: object
          maxProperties: 64
          additionalProperties: { type: string, maxLength: 4096 }
          description: The request headers listed in the operation description, never `Cookie` or `Authorization`. Names are case-insensitive.
    BotCheckResponse:
      type: object
      required: [bot, ip, vpn, proxy]
      properties:
        bot:
          type: object
          required: [result, types, info]
          properties:
            result: { $ref: '#/components/schemas/BotResult' }
            types:
              type: array
              items: { $ref: '#/components/schemas/BotHeaderType' }
            info:
              oneOf:
                - type: 'null'
                - $ref: '#/components/schemas/BotInfo'
        ip:
          type: object
          required: [country, asn, asName, datacenter, tor, relay, blocklist]
          properties:
            country: { type: [string, 'null'] }
            asn: { type: [integer, 'null'] }
            asName: { type: [string, 'null'], description: 'Where you show it, add "IP address data powered by IPinfo".' }
            datacenter: { type: [boolean, 'null'] }
            tor: { type: [boolean, 'null'] }
            relay: { type: [boolean, 'null'] }
            blocklist:
              type: object
              required: [result, sources]
              properties:
                result: { type: [boolean, 'null'] }
                sources:
                  type: array
                  items: { type: string }
        vpn:
          type: object
          required: [result, methods]
          properties:
            result: { type: [boolean, 'null'] }
            methods:
              type: array
              items: { type: string, enum: [ip_range, relay], description: New values may be added; ignore a value you do not know. }
        proxy:
          type: object
          required: [result, type]
          properties:
            result: { type: [boolean, 'null'] }
            type: { enum: [datacenter, null] }
    BotCheckInvalid:
      type: object
      required: [ok, error, field]
      properties:
        ok: { const: false }
        error: { enum: [invalid_request, invalid_ip], description: New values may be added. }
        field: { type: string }
    RuleAction:
      type: object
      required: [type, rulesetVersion]
      properties:
        type: { enum: [allow, human_check, deny, none], description: '`none` when the ruleset was evaluated and no rule matched. New values may be added; treat an unknown value as `none`.' }
        ruleId: { type: string, description: 'The matching rule; absent when `type` is `none`.' }
        ruleName: { type: string }
        rulesetVersion: { type: integer }
    EventCore:
      type: object
      required: [v, eventId, projectId, visitorId, createdAt, platform, protocol, isNew, identityConfidence, trustScore, linkedId, tags, suspect, ip, ipInfo, origin, agentVersion]
      properties:
        v: { const: 1 }
        eventId: { type: string, pattern: '^ev_[0-9A-HJKMNP-TV-Z]{20}$' }
        projectId: { type: string }
        visitorId: { type: string }
        createdAt: { type: string, format: date-time }
        platform: { $ref: '#/components/schemas/Platform' }
        protocol: { enum: [1, 2] }
        isNew: { type: boolean }
        identityConfidence: { type: number }
        trustScore: { type: number }
        linkedId: { type: [string, 'null'] }
        tags:
          type:
            - object
            - 'null'
        suspect: { type: boolean, description: Yours to set with `updateEvent`; always false in a webhook. }
        ip: { type: string }
        ipInfo:
          type: object
          required: [asn, country, timezone]
          properties:
            asn: { type: [integer, 'null'] }
            country: { type: [string, 'null'] }
            timezone: { type: [string, 'null'] }
        origin: { type: [string, 'null'] }
        agentVersion: { type: [string, 'null'] }
        proxied: { type: boolean, description: True when the request reached the API through the project's Cloudflare proxy integration with a valid proxy secret. }
        ruleAction:
          description: 'What the project''s ruleset decided for this event. Null when the ruleset is disabled. Never in the browser or app identify answer or a sealed result.'
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/RuleAction'
        signals:
          oneOf:
            - $ref: '#/components/schemas/Signals'
            - type: 'null'
    Event:
      description: An event as the read API returns it (`signals` is always present, `null` when the event has none).
      allOf:
        - $ref: '#/components/schemas/EventCore'
        - type: object
          required: [signals]
      example:
        v: 1
        eventId: ev_0123456789ABCDEFGHJK
        projectId: prj_0123456789ab
        visitorId: v4_visitor1
        createdAt: '2026-10-25T09:30:00.000Z'
        platform: web
        protocol: 2
        isNew: false
        identityConfidence: 92
        trustScore: 85
        linkedId: user_123
        tags: { page: checkout }
        suspect: false
        ip: 203.0.113.7
        ipInfo: { asn: 64500, country: DE, timezone: Europe/Berlin }
        origin: 'https://shop.example'
        agentVersion: 0.9.0
        signals:
          version: 2
          ip:
            country: DE
            city: Berlin
            timezone: Europe/Berlin
            asn: 64500
            asName: Example Hosting GmbH
            datacenter: true
            tor: false
            relay: false
            blocklist: { result: false, sources: [] }
          vpn: { result: true, methods: [timezone_mismatch] }
          proxy: { result: true, type: datacenter }
          bot: { result: bad, types: [headless], info: null }
          incognito: false
          tampering: { result: false, antiDetectBrowser: false }
          privacyBrowser: { result: false, name: null }
          virtualMachine: false
          devtools: null
          velocity:
            distinctIp: { 5m: 1, 1h: 2, 24h: 3 }
            distinctCountry: { 5m: 1, 1h: 1, 24h: 2 }
            distinctLinkedId: { 5m: 1, 1h: 1, 24h: 1 }
            events: { 5m: 2, 1h: 4, 24h: 9 }
          mobile: null
    WebhookEvent:
      description: |
        The event JSON as a webhook body. `signals` can be missing from a payload queued before server 0.43.0
        (treat missing and `null` alike); `proxied` and `ruleAction` can be missing from a payload queued before server 0.45.0. A console "Test" delivery adds `"test": true`.
      allOf:
        - $ref: '#/components/schemas/EventCore'
        - type: object
          properties:
            test: { const: true }
    EventPage:
      type: object
      required: [events, next]
      properties:
        events:
          type: array
          items: { $ref: '#/components/schemas/Event' }
        next: { type: [string, 'null'], description: 'Pass as `cursor`. Can be non-null on an exactly full last page, so the next page may be empty: loop until it is null.' }
    EventPatch:
      type: object
      minProperties: 1
      additionalProperties: false
      properties:
        linkedId: { type: [string, 'null'], minLength: 1, maxLength: 256 }
        tags: { type: [object, 'null'] }
        suspect: { type: boolean }
    Visitor:
      type: object
      required: [visitorId, firstSeenAt, lastSeenAt, eventCount, linkedIds, recentEvents]
      properties:
        visitorId: { type: string }
        firstSeenAt: { type: string, format: date-time }
        lastSeenAt: { type: string, format: date-time }
        eventCount: { type: integer }
        linkedIds:
          type: array
          description: Newest first.
          items: { type: string }
        recentEvents:
          type: array
          items: { $ref: '#/components/schemas/Event' }
    VisitorDeleted:
      type: object
      required: [deleted, shared]
      properties:
        deleted: { const: true }
        shared: { type: boolean, description: True when another project also uses the device; its rows were kept. }
