openapi: 3.1.0
info:
  title: SigWise API
  version: 1.0.0
  summary: Send events about your objects, get typed answers back.
  description: |
    SigWise reads the events and messages on your platform and answers the
    questions you configure about each object (a user, a listing, an order)
    as typed **signals**: probabilities (`noul`), positions on a labelled
    spectrum (`score`) and one-of-N classifications (`choice`).

    You configure signals, send events about objects, and read the latest
    answers, or get them pushed to you through webhooks and rules.

    ## Authentication

    Integrations authenticate with an API key, which is a pair:

    - a public **key ID**, sent as the `api_key` query parameter
      (or as the JWT header `kid`);
    - a **signing secret**, which never leaves your server.

    Every request carries `Authorization: Bearer <jwt>`: an HS256 JWT you sign
    with the whole secret string. It needs `iat` and `exp` claims at most five
    minutes apart (with ±60 s of clock skew), and can bind itself to one
    request with `htm` (the HTTP method) and `htu` (the path). The official
    SDKs do this for you on every request.

    ## Errors

    Every error uses the same envelope,
    `{"error": {"code": "not_found", "message": "signal not found"}}`, with a
    stable machine-readable `code` and a human-readable `message`.

    ## Rate limits

    Requests under `/v1` are limited per account (every API key and console
    session of an account share one budget; 3,000 requests a minute by
    default). Over the limit, the API answers `429` with code `rate_limited`
    and a `Retry-After` header in seconds. Batch several events into one
    ingest request rather than sending them one by one.
  contact:
    name: SigWise
    url: https://sigwise.ai
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary
servers:
  - url: https://api.sigwise.ai
    description: Production
  - url: http://localhost:8080
    description: Local development

security:
  - ApiKeyId: []
    SignedJWT: []
  - ConsoleToken: []

tags:
  - name: Objects
    description: |
      An object is anything you want answers about: a user, a listing, an
      order. It is identified by your own `object_id` and exists as soon as
      you send its first event.
  - name: Events
    description: |
      Events and messages are the evidence an object's answers are computed
      from. Ingestion is asynchronous by default and returns `202`; set
      `wait: true` to score the object inline instead (direct moderation).
  - name: Signals
    description: |
      A signal is a question you ask about every object, such as "is this a
      scammer?" (`noul`), "how trustworthy is this user?" (`score`) or "what is
      their buyer intent?" (`choice`).
  - name: Webhooks
    description: |
      Webhook endpoints receive a signed `POST` for the event types they
      subscribe to: `analysis.completed` each time an analysis completes, and
      `rule.triggered` when a rule with a webhook action fires. Deliveries
      retry with backoff and land in a dead-letter queue you can replay.
  - name: Rules
    description: |
      A rule fires an action (email, Slack or webhook) when an object's signal
      values cross a line you care about, such as `is_scammer >= 90`.
  - name: API keys
    description: Create, rotate and revoke the keys your integrations sign requests with.
  - name: Billing
    description: Prepaid balance, the billing ledger, and card top-ups.
  - name: Usage
    description: Analysis volume, spend and token usage over a date range.
  - name: Account
    description: The authenticated principal and tenant settings.
  - name: Console
    description: |
      Endpoints used by the SigWise console for human sign-in and account
      management. They authenticate with a console token, not an API key, and
      are not part of the SDKs.
  - name: System
    description: Health checks and this specification.

x-tagGroups:
  - name: Core
    tags: [Objects, Events, Signals]
  - name: Automation
    tags: [Webhooks, Rules]
  - name: Account
    tags: [Account, API keys, Billing, Usage]
  - name: Internal
    tags: [Console, System]

paths:
  # ------------------------------------------------------------------ System
  /healthz:
    get:
      tags: [System]
      operationId: healthz
      summary: Liveness
      description: Returns `200` as long as the process is serving. It does not check dependencies.
      security: []
      x-sdk: false
      responses:
        "200":
          description: The process is up.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
              example: { status: ok }
  /metrics:
    get:
      tags: [System]
      operationId: metrics
      summary: Prometheus metrics
      description: |
        Operational metrics in the Prometheus text format: request rates and
        latency by route, analysis outcomes, analyzer latency and throttling,
        queue depth and lag, and running jobs per tenant. For the operator of
        a deployment, not for integrations.

        Requires `Authorization: Bearer <METRICS_TOKEN>` when a token is
        configured, and is only served in production when one is. Returns
        `404` when metrics are disabled.
      security: []
      x-sdk: false
      responses:
        "200":
          description: The current metrics.
          content:
            text/plain:
              schema: { type: string }
        "401":
          description: The metrics token is missing or wrong.
        "404": { $ref: "#/components/responses/NotFound" }
  /readyz:
    get:
      tags: [System]
      operationId: readyz
      summary: Readiness
      description: Returns `200` only when the database is reachable, so load balancers stop routing traffic during an outage.
      security: []
      x-sdk: false
      responses:
        "200":
          description: Ready to serve traffic.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Health" }
              example: { status: ready }
        "503":
          description: The database is unreachable.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: { code: not_ready, message: database unavailable } }
  /openapi.yaml:
    get:
      tags: [System]
      operationId: getOpenAPIYAML
      summary: OpenAPI specification (YAML)
      description: This document, as served by the API you are talking to.
      security: []
      x-sdk: false
      responses:
        "200":
          description: The OpenAPI 3.1 document.
          content:
            application/yaml:
              schema: { type: string }
  /openapi.json:
    get:
      tags: [System]
      operationId: getOpenAPIJSON
      summary: OpenAPI specification (JSON)
      description: This document as JSON.
      security: []
      x-sdk: false
      responses:
        "200":
          description: The OpenAPI 3.1 document.
          content:
            application/json:
              schema: { type: object }

  # ----------------------------------------------------------------- Console auth
  /auth/console/register:
    post:
      tags: [Console]
      operationId: consoleRegister
      summary: Sign up
      description: |
        Self-service signup: provisions a new tenant and its first admin user,
        then signs in. New tenants get the signup credit and the default
        signals. Returns `403` when the deployment disables registration.
      security: []
      x-sdk: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RegisterRequest" }
      responses:
        "201":
          description: Account created and signed in.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Token" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
  /auth/console/login:
    post:
      tags: [Console]
      operationId: consoleLogin
      summary: Sign in
      description: |
        Verifies an email and password and returns a console token.

        For an account with [two-factor authentication](#tag/Console/operation/getMfa)
        on, a request without `code` gets `401` with the code `mfa_required`;
        repeat it with a code from the authenticator app, or an unused recovery
        code. Repeated failures lock the account and the caller's address for 15
        minutes: the API answers `429` with `Retry-After`.
      security: []
      x-sdk: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/LoginRequest" }
      responses:
        "200":
          description: Signed in.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Token" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /auth/token:
    post:
      tags: [Console]
      operationId: exchangeToken
      summary: Exchange a legacy key for a token
      deprecated: true
      description: |
        **Deprecated.** Exchanges a legacy API key and secret for a short-lived
        tenant token. Integrations now sign each request themselves; this path
        is kept for one release and logs a warning on every use.
      security: []
      x-sdk: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TokenExchangeRequest" }
      responses:
        "200":
          description: A tenant token.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Token" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /auth/console/password/forgot:
    post:
      tags: [Console]
      operationId: forgotPassword
      summary: Request a password reset
      description: |
        Emails a single-use reset link valid for one hour. It answers `202`
        with the same body whether or not the email has an account, and is
        throttled per client IP and per email.
      security: []
      x-sdk: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ForgotPasswordRequest" }
      responses:
        "202":
          description: Accepted.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Message" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /auth/console/password/reset:
    post:
      tags: [Console]
      operationId: resetPassword
      summary: Reset a password
      description: Sets a new password with a reset token. Every existing session is signed out.
      security: []
      x-sdk: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ResetPasswordRequest" }
      responses:
        "204": { description: Password reset. }
        "400": { $ref: "#/components/responses/BadRequest" }
  /auth/console/oauth/providers:
    get:
      tags: [Console]
      operationId: listOAuthProviders
      summary: List social sign-in providers
      security: []
      x-sdk: false
      responses:
        "200":
          description: The providers configured on this deployment.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthProviders" }
  /auth/console/oauth/{provider}/authorize:
    post:
      tags: [Console]
      operationId: authorizeOAuth
      summary: Get a provider authorization URL
      description: Returns the provider's authorization URL for a `state` and a PKCE S256 `code_challenge`.
      security: []
      x-sdk: false
      parameters:
        - $ref: "#/components/parameters/Provider"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OAuthAuthorizeRequest" }
      responses:
        "200":
          description: The URL to send the browser to.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthorizationURL" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }
  /auth/console/oauth/{provider}:
    post:
      tags: [Console]
      operationId: oauthLogin
      summary: Sign in with a provider
      description: |
        Completes a social sign-in: exchanges the authorization code and PKCE
        verifier, then signs in the linked user, links a verified email to an
        existing user, or signs up a new tenant (`201`, `new_user: true`).
      security: []
      x-sdk: false
      parameters:
        - $ref: "#/components/parameters/Provider"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OAuthCodeRequest" }
      responses:
        "200":
          description: Signed in to an existing account.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Token" }
        "201":
          description: A new account was created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Token" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "502": { $ref: "#/components/responses/BadGateway" }
  /webhooks/stripe:
    post:
      tags: [System]
      operationId: stripeWebhook
      summary: Stripe events
      description: |
        Receives Stripe events, verified against the `Stripe-Signature` header.
        A paid top-up Checkout Session is credited to the tenant exactly once.
        Called by Stripe, not by integrations.
      security: []
      x-sdk: false
      parameters:
        - name: Stripe-Signature
          in: header
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object, additionalProperties: true }
      responses:
        "200":
          description: Received.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StripeWebhookAck" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  # ------------------------------------------------------------------ Me
  /v1/me:
    get:
      tags: [Account]
      operationId: getMe
      summary: Get the current principal
      description: |
        Returns the tenant the credential belongs to. For console tokens it
        also returns the signed-in user and whether they finished onboarding.
      x-sdk-resource: me
      x-sdk-method: get
      responses:
        "200":
          description: The authenticated principal.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Me" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/me/onboarded:
    post:
      tags: [Console]
      operationId: setOnboarded
      summary: Mark onboarding complete
      security:
        - ConsoleToken: []
      x-sdk: false
      responses:
        "204": { description: Marked onboarded. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/me/password:
    post:
      tags: [Console]
      operationId: changePassword
      summary: Change password
      description: Changes the signed-in user's password, signs out every other session, and returns a new console token.
      security:
        - ConsoleToken: []
      x-sdk: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ChangePasswordRequest" }
      responses:
        "200":
          description: Password changed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Token" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/me/mfa:
    get:
      tags: [Console]
      operationId: getMfa
      summary: Get two-factor status
      description: Whether two-factor authentication is on for the signed-in user, and how many recovery codes are left.
      security:
        - ConsoleToken: []
      x-sdk: false
      responses:
        "200":
          description: The two-factor status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MfaStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/me/mfa/setup:
    post:
      tags: [Console]
      operationId: setupMfa
      summary: Start two-factor setup
      description: |
        Issues a new secret to add to an authenticator app. Two-factor stays off
        until `POST /v1/me/mfa/enable` confirms a code from it. Calling this
        again before enabling replaces the secret. Returns `409` when
        two-factor is already on.
      security:
        - ConsoleToken: []
      x-sdk: false
      responses:
        "200":
          description: The secret, as text and as an `otpauth://` URI for a QR code.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MfaSetup" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/me/mfa/enable:
    post:
      tags: [Console]
      operationId: enableMfa
      summary: Turn on two-factor
      description: |
        Confirms the secret from setup with a current code and turns two-factor
        on. Returns eight single-use recovery codes, **once**; each signs in
        once if the authenticator is lost.
      security:
        - ConsoleToken: []
      x-sdk: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MfaEnableRequest" }
      responses:
        "200":
          description: Two-factor is on.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MfaRecoveryCodes" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/me/mfa/disable:
    post:
      tags: [Console]
      operationId: disableMfa
      summary: Turn off two-factor
      description: |
        Needs the account password (when it has one) and a current code or an
        unused recovery code, so a stolen session alone can't remove it.
      security:
        - ConsoleToken: []
      x-sdk: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MfaDisableRequest" }
      responses:
        "204": { description: Two-factor is off. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/me/identities:
    get:
      tags: [Console]
      operationId: listIdentities
      summary: List sign-in methods
      security:
        - ConsoleToken: []
      x-sdk: false
      responses:
        "200":
          description: Password status and linked providers.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Identities" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
  /v1/me/identities/{provider}:
    post:
      tags: [Console]
      operationId: linkIdentity
      summary: Link a provider account
      security:
        - ConsoleToken: []
      x-sdk: false
      parameters:
        - $ref: "#/components/parameters/Provider"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/OAuthCodeRequest" }
      responses:
        "201":
          description: Linked.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Identity" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
    delete:
      tags: [Console]
      operationId: unlinkIdentity
      summary: Unlink a provider account
      description: Refused with `409` when it is the user's last sign-in method.
      security:
        - ConsoleToken: []
      x-sdk: false
      parameters:
        - $ref: "#/components/parameters/Provider"
      responses:
        "204": { description: Unlinked. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
  /v1/me/notifications:
    get:
      tags: [Console]
      operationId: getNotificationPreferences
      summary: Get email preferences
      security:
        - ConsoleToken: []
      x-sdk: false
      responses:
        "200":
          description: Every account email and whether the user receives it.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotificationPreferences" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    put:
      tags: [Console]
      operationId: updateNotificationPreferences
      summary: Update email preferences
      description: |
        Maps an email event to whether the user wants it, e.g.
        `{"low_balance": false}`. Events left out keep their setting. Critical
        emails can't be turned off.
      security:
        - ConsoleToken: []
      x-sdk: false
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/NotificationPreferencesUpdate" }
      responses:
        "200":
          description: The updated preferences.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/NotificationPreferences" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  # ------------------------------------------------------------------ Objects
  /v1/overview:
    get:
      tags: [Objects]
      operationId: getOverview
      summary: Get tenant overview
      description: Tenant-wide totals and per-signal value distributions, computed with SQL aggregates.
      x-sdk-resource: overview
      x-sdk-method: get
      responses:
        "200":
          description: The overview.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Overview" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/analyze:
    post:
      tags: [Objects]
      operationId: analyzeAllObjects
      summary: Re-analyze every object
      description: |
        Schedules a re-analysis of every object the tenant has, typically after
        changing signal configuration. Jobs run at bulk priority, behind live
        traffic. Each analysis is billed.
      x-sdk-resource: objects
      x-sdk-method: analyzeAll
      responses:
        "202":
          description: Scheduled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalyzeAllScheduled" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/objects:
    get:
      tags: [Objects]
      operationId: listObjects
      summary: List objects
      description: |
        Returns one page of objects with their latest analysis.

        `q` is either a substring of the object ID or display name, or a
        signal query such as `is_scammer > 90 and trust_score < 1` (see
        [Search and conditions](https://sigwise.ai/docs/guides/queries)).
      x-sdk-resource: objects
      x-sdk-method: list
      parameters:
        - name: q
          in: query
          description: Substring search, or a signal query like `is_scammer >= 90, buyer_intent = ready_to_buy`.
          schema: { type: string }
          example: is_scammer >= 90
        - name: sort
          in: query
          description: "`recent` (last seen first, the default), `flagged` (highest yes/no probability first) or `trust` (highest score first)."
          schema:
            type: string
            enum: [recent, flagged, trust]
            default: recent
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 100 }
        - name: offset
          in: query
          description: Rows to skip. Prefer `cursor` for deep pages; offsets get slower the further they go.
          schema: { type: integer, minimum: 0, default: 0 }
        - name: cursor
          in: query
          description: |
            The `next_cursor` of the previous page. Pages by position rather
            than offset, so every page is equally fast; `offset` is ignored when
            set. Keep `q` and `sort` the same across pages. Signal queries page
            by `offset` only.
          schema: { type: string }
      responses:
        "200":
          description: A page of objects.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ObjectList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
  /v1/objects/{object_id}:
    get:
      tags: [Objects]
      operationId: getObject
      summary: Get an object's analysis
      description: |
        Returns the latest answer for each signal computed for the object, plus
        the configured signals that have no answer yet (`pending`).

        Reads are cached for a few seconds; the `X-Cache` header says whether
        this one was a `HIT` or a `MISS`. Ingesting events and completed
        analyses invalidate the cache.
      x-sdk-resource: objects
      x-sdk-method: get
      parameters:
        - $ref: "#/components/parameters/ObjectId"
      responses:
        "200":
          description: The object's analysis.
          headers:
            X-Cache:
              description: "`HIT` when served from cache, `MISS` otherwise."
              schema: { type: string, enum: [HIT, MISS] }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ObjectAnalysis" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/objects/{object_id}/events:
    post:
      tags: [Events]
      operationId: ingestEvents
      summary: Ingest events
      description: |
        Records one or more events or messages about an object.

        **Asynchronous (default).** Returns `202` immediately and schedules a
        debounced analysis: a burst of events for one object coalesces into a
        single analysis once activity settles. Read the answers with
        `GET /v1/objects/{object_id}` or receive them by webhook.

        **Synchronous (`wait: true`).** Blocks on the analyzer and returns the
        verdict inline with `200`, so you can approve, review or block content
        before publishing it. Restrict scoring to some signals with `signals`,
        and score the request's events in isolation with
        `include_history: false`.

        Returns `402` when the tenant's balance is empty. A synchronous request
        returns `429` when the account already has the maximum number of
        synchronous analyses in flight (8 by default), and `503` with
        `analyzer_busy` when the analyzer is throttling; the events are
        recorded either way, so don't resend them: after `Retry-After`, request
        an analysis with `POST /v1/objects/{object_id}/analyze` and read the
        answers with `GET /v1/objects/{object_id}` or by webhook.

        **Safe retries.** Send an `Idempotency-Key` header (any unique string,
        such as a UUID) and retry with the same key after a timeout or a `5xx`:
        the events are recorded once. A repeat of an asynchronous request is
        answered like the first and carries `Idempotent-Replayed: true`. A repeat
        of a synchronous request returns `409` without analyzing (or charging)
        again; read the verdict with `GET /v1/objects/{object_id}`. Reusing a key
        for a different object returns `422`. Keys are remembered for 24 hours.
      x-sdk-resource: events
      x-sdk-method: ingest
      parameters:
        - $ref: "#/components/parameters/ObjectId"
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IngestRequest" }
            examples:
              async:
                summary: Asynchronous ingest
                value:
                  object_type: user
                  events:
                    - { type: event, name: profile.updated, metadata: { field: bio } }
                    - { type: message, content: "is this still available? can I pay by wire?" }
              sync:
                summary: Direct moderation
                value:
                  wait: true
                  signals: [is_scammer]
                  events:
                    - { type: message, content: "pay me by wire and I double it" }
      responses:
        "200":
          description: "`wait: true`: the events were recorded and the object scored."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModerationVerdict" }
        "202":
          description: The events were recorded and an analysis scheduled.
          headers:
            Idempotent-Replayed:
              description: "`true` when this was a retry of an earlier request with the same `Idempotency-Key`; nothing was recorded again."
              schema: { type: string, enum: ["true"] }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestAccepted" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/IdempotencyKeyReused" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "502": { $ref: "#/components/responses/BadGateway" }
        "503": { $ref: "#/components/responses/AnalyzerBusy" }
    get:
      tags: [Events]
      operationId: listObjectEvents
      summary: List an object's events
      description: Returns up to the 200 most recent events and messages recorded for the object, newest first.
      x-sdk-resource: events
      x-sdk-method: list
      parameters:
        - $ref: "#/components/parameters/ObjectId"
      responses:
        "200":
          description: The object's events.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/objects/{object_id}/ingest:
    post:
      tags: [Events]
      operationId: ingestEventsAlias
      summary: Ingest events (alias)
      deprecated: true
      description: An alias of `POST /v1/objects/{object_id}/events`. Use that path instead.
      x-sdk: false
      parameters:
        - $ref: "#/components/parameters/ObjectId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IngestRequest" }
      responses:
        "200":
          description: "`wait: true`: the events were recorded and the object scored."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ModerationVerdict" }
        "202":
          description: The events were recorded and an analysis scheduled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IngestAccepted" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/PaymentRequired" }
  /v1/objects/{object_id}/state:
    get:
      tags: [Objects]
      operationId: getObjectState
      summary: Get an object's compacted history
      description: |
        Older events are folded into a rolling summary so the analyzer gets a
        bounded payload while keeping long-term signal. This returns that
        summary and the compaction cursor.
      x-sdk-resource: objects
      x-sdk-method: getState
      parameters:
        - $ref: "#/components/parameters/ObjectId"
      responses:
        "200":
          description: The compacted state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ObjectState" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/objects/{object_id}/analyze:
    post:
      tags: [Objects]
      operationId: analyzeObject
      summary: Re-analyze an object
      description: |
        Schedules an immediate re-analysis without a new event, for example
        right after adding a signal. Runs at live priority; poll
        `GET /v1/objects/{object_id}` for the result. Returns `404` when the
        object has no events.
      x-sdk-resource: objects
      x-sdk-method: analyze
      parameters:
        - $ref: "#/components/parameters/ObjectId"
      responses:
        "202":
          description: Scheduled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AnalyzeScheduled" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ------------------------------------------------------------------ Signals
  /v1/signals:
    get:
      tags: [Signals]
      operationId: listSignals
      summary: List signals
      x-sdk-resource: signals
      x-sdk-method: list
      responses:
        "200":
          description: Every configured signal, enabled or not.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SignalList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/signals/{key}:
    put:
      tags: [Signals]
      operationId: upsertSignal
      summary: Create or update a signal
      description: |
        Creates the signal, or replaces its definition. `instructions` and
        `criteria` are passed to the analyzer verbatim; the shape of
        `criteria` depends on `type`:

        | `type`   | `criteria`                                           |
        |----------|------------------------------------------------------|
        | `noul`   | object with optional `true` / `false` descriptions   |
        | `score`  | ordered array of level descriptions, low to high     |
        | `choice` | object mapping each option to a description or null |

        Creating a signal does not analyze existing objects unless the tenant
        setting `auto_backfill_signals` is on; start a backfill explicitly with
        `POST /v1/signals/{key}/backfill`.
      x-sdk-resource: signals
      x-sdk-method: upsert
      parameters:
        - $ref: "#/components/parameters/SignalKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SignalInput" }
            examples:
              noul:
                summary: Yes/no probability
                value:
                  type: noul
                  instructions: Decide if this user is likely a scammer.
                  criteria: { "true": clear scam signals, "false": legitimate behaviour }
              score:
                summary: Labelled spectrum
                value:
                  type: score
                  instructions: Rate overall trustworthiness.
                  criteria: [high risk, neutral, trusted]
              choice:
                summary: One of N
                value:
                  type: choice
                  instructions: Classify buyer intent.
                  criteria: { browsing: null, comparing: null, ready_to_buy: null }
      responses:
        "200":
          description: The saved signal.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Signal" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    delete:
      tags: [Signals]
      operationId: deleteSignal
      summary: Delete a signal
      x-sdk-resource: signals
      x-sdk-method: delete
      parameters:
        - $ref: "#/components/parameters/SignalKey"
      responses:
        "204": { description: Deleted. }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/signals/{key}/backfill:
    get:
      tags: [Signals]
      operationId: getSignalBackfill
      summary: Get backfill estimate and progress
      description: |
        Returns how many existing objects have no answer for the signal and
        what analyzing them would cost now, plus the progress of the most
        recent backfill (`null` if none was started).
      x-sdk-resource: signals
      x-sdk-method: getBackfill
      parameters:
        - $ref: "#/components/parameters/SignalKey"
      responses:
        "200":
          description: Estimate and progress.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BackfillStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    post:
      tags: [Signals]
      operationId: startSignalBackfill
      summary: Start a backfill
      description: |
        Re-analyzes, for this signal only, every existing object that has no
        answer for it. Jobs run at bulk priority so they never delay live
        ingest. Each analysis is billed. Returns `409` if the signal is
        disabled.
      x-sdk-resource: signals
      x-sdk-method: startBackfill
      parameters:
        - $ref: "#/components/parameters/SignalKey"
      responses:
        "202":
          description: Scheduled.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SignalBackfill" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }

  # ------------------------------------------------------------------ Settings
  /v1/settings:
    get:
      tags: [Account]
      operationId: getSettings
      summary: Get tenant settings
      x-sdk-resource: settings
      x-sdk-method: get
      responses:
        "200":
          description: The settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settings" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [Account]
      operationId: updateSettings
      summary: Update tenant settings
      description: Omitted fields are left unchanged.
      x-sdk-resource: settings
      x-sdk-method: update
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/SettingsUpdate" }
      responses:
        "200":
          description: The updated settings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Settings" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ------------------------------------------------------------------ Webhooks
  /v1/webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhook endpoints
      x-sdk-resource: webhooks
      x-sdk-method: list
      responses:
        "200":
          description: The endpoints, without their secrets.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Register a webhook endpoint
      description: |
        Registers an endpoint and returns its signing secret. **The secret is
        shown only once**; store it to verify deliveries.
      x-sdk-resource: webhooks
      x-sdk-method: create
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointCreate" }
      responses:
        "201":
          description: Registered.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointCreated" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/webhooks/{endpoint_id}:
    get:
      tags: [Webhooks]
      operationId: getWebhook
      summary: Get a webhook endpoint
      x-sdk-resource: webhooks
      x-sdk-method: get
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      responses:
        "200":
          description: The endpoint.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Webhooks]
      operationId: updateWebhook
      summary: Update a webhook endpoint
      description: Omitted fields are left unchanged.
      x-sdk-resource: webhooks
      x-sdk-method: update
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEndpointUpdate" }
      responses:
        "200":
          description: The updated endpoint.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpoint" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Delete a webhook endpoint
      description: Removes the endpoint and its deliveries.
      x-sdk-resource: webhooks
      x-sdk-method: delete
      parameters:
        - $ref: "#/components/parameters/EndpointId"
      responses:
        "204": { description: Deleted. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/webhook_deliveries:
    get:
      tags: [Webhooks]
      operationId: listWebhookDeliveries
      summary: List webhook deliveries
      description: Recent deliveries, newest first. Filter with `status=dead` to inspect the dead-letter queue.
      x-sdk-resource: webhookDeliveries
      x-sdk-method: list
      parameters:
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/DeliveryStatus" }
        - name: endpoint_id
          in: query
          schema: { type: string, format: uuid }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        "200":
          description: The deliveries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDeliveryList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/webhook_deliveries/{delivery_id}/replay:
    post:
      tags: [Webhooks]
      operationId: replayWebhookDelivery
      summary: Replay a delivery
      description: Re-queues a delivery (for example one in the dead-letter queue) for another attempt.
      x-sdk-resource: webhookDeliveries
      x-sdk-method: replay
      parameters:
        - $ref: "#/components/parameters/DeliveryId"
      responses:
        "202":
          description: Re-queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDelivery" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ------------------------------------------------------------------ Rules
  /v1/rules:
    get:
      tags: [Rules]
      operationId: listRules
      summary: List rules
      x-sdk-resource: rules
      x-sdk-method: list
      responses:
        "200":
          description: The rules.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [Rules]
      operationId: createRule
      summary: Create a rule
      description: |
        The condition is validated against the tenant's signals, and a
        `webhook` action must reference a registered endpoint subscribed to
        `rule.triggered`.
      x-sdk-resource: rules
      x-sdk-method: create
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleCreate" }
            example:
              name: scammer alert
              when: is_scammer >= 90 and trust_score < 1
              action_type: slack
              action_config: { webhook_url: "https://hooks.slack.com/services/T000/B000/XXXX" }
              cooldown_seconds: 3600
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Rule" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/rules/{rule_id}:
    get:
      tags: [Rules]
      operationId: getRule
      summary: Get a rule
      x-sdk-resource: rules
      x-sdk-method: get
      parameters:
        - $ref: "#/components/parameters/RuleId"
      responses:
        "200":
          description: The rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Rule" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      tags: [Rules]
      operationId: updateRule
      summary: Update a rule
      description: Omitted fields are left unchanged. The resulting condition and action are re-validated.
      x-sdk-resource: rules
      x-sdk-method: update
      parameters:
        - $ref: "#/components/parameters/RuleId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleUpdate" }
      responses:
        "200":
          description: The updated rule.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Rule" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [Rules]
      operationId: deleteRule
      summary: Delete a rule
      x-sdk-resource: rules
      x-sdk-method: delete
      parameters:
        - $ref: "#/components/parameters/RuleId"
      responses:
        "204": { description: Deleted. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/rule_firings:
    get:
      tags: [Rules]
      operationId: listRuleFirings
      summary: List rule firings
      description: The audit log of rule firings and their action outcomes, newest first.
      x-sdk-resource: ruleFirings
      x-sdk-method: list
      parameters:
        - name: rule_id
          in: query
          schema: { type: string, format: uuid }
        - name: object_id
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      responses:
        "200":
          description: The firings.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RuleFiringList" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ------------------------------------------------------------------ API keys
  /v1/api_keys:
    get:
      tags: [API keys]
      operationId: listApiKeys
      summary: List API keys
      description: Revoked keys are not listed. Secrets are never returned.
      x-sdk-resource: apiKeys
      x-sdk-method: list
      responses:
        "200":
          description: The keys.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIKeyList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [API keys]
      operationId: createApiKey
      summary: Create an API key
      description: Mints a key ID and signing secret. **The secret is shown only once.**
      x-sdk-resource: apiKeys
      x-sdk-method: create
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/APIKeyCreate" }
      responses:
        "201":
          description: Created.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIKeySecret" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/api_keys/{key_id}:
    patch:
      tags: [API keys]
      operationId: updateApiKey
      summary: Update an API key
      description: Rename a key, or deactivate and reactivate it. Use `DELETE` to revoke.
      x-sdk-resource: apiKeys
      x-sdk-method: update
      parameters:
        - $ref: "#/components/parameters/KeyId"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/APIKeyUpdate" }
      responses:
        "200":
          description: The updated key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIKey" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [API keys]
      operationId: deleteApiKey
      summary: Revoke an API key
      description: The key stops authenticating immediately. Its ID is never reissued.
      x-sdk-resource: apiKeys
      x-sdk-method: revoke
      parameters:
        - $ref: "#/components/parameters/KeyId"
      responses:
        "204": { description: Revoked. }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /v1/api_keys/{key_id}/rotate:
    post:
      tags: [API keys]
      operationId: rotateApiKey
      summary: Rotate an API key's secret
      description: |
        Keeps the key ID and replaces the secret. The old secret stops working
        immediately; the new one is returned only once.
      x-sdk-resource: apiKeys
      x-sdk-method: rotate
      parameters:
        - $ref: "#/components/parameters/KeyId"
      responses:
        "200":
          description: Rotated.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/APIKeySecret" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  # ------------------------------------------------------------------ Billing
  /v1/billing:
    get:
      tags: [Billing]
      operationId: getBalance
      summary: Get the prepaid balance
      x-sdk-resource: billing
      x-sdk-method: getBalance
      responses:
        "200":
          description: The balance and billing configuration.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Balance" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/billing/ledger:
    get:
      tags: [Billing]
      operationId: listLedger
      summary: List ledger entries
      description: |
        One page of the billing ledger, newest first. Pass the previous page's
        `next_cursor` as `cursor` to continue; it is empty on the last page.
      x-sdk-resource: billing
      x-sdk-method: listLedger
      parameters:
        - name: type
          in: query
          description: Only credits (funds added) or only charges (analyses).
          schema: { type: string, enum: [credit, charge] }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 50 }
        - name: cursor
          in: query
          schema: { type: string }
      responses:
        "200":
          description: A page of entries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LedgerPage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /v1/billing/checkout:
    post:
      tags: [Billing]
      operationId: createCheckout
      summary: Start a card top-up
      description: |
        Creates a hosted Stripe Checkout Session for the amount and returns its
        URL. The balance is credited only once Stripe confirms the payment.
        Returns `503` when card payments are not configured.
      x-sdk-resource: billing
      x-sdk-method: createCheckout
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CheckoutCreate" }
      responses:
        "201":
          description: The Checkout Session.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutSession" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { $ref: "#/components/responses/BadGateway" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
  /v1/billing/checkout/{session_id}:
    get:
      tags: [Billing]
      operationId: getCheckout
      summary: Get a top-up's status
      description: Whether the Checkout Session's payment has been credited to the balance yet.
      x-sdk-resource: billing
      x-sdk-method: getCheckout
      parameters:
        - name: session_id
          in: path
          required: true
          description: The Checkout Session ID (`cs_…`).
          schema: { type: string }
      responses:
        "200":
          description: The top-up's status.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CheckoutStatus" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ------------------------------------------------------------------ Usage
  /v1/usage:
    get:
      tags: [Usage]
      operationId: getUsage
      summary: Get usage
      description: |
        Analyses, spend and tokens over an inclusive UTC date range (at most
        366 days), with a zero-filled daily series and breakdowns by model,
        source (`async` / `sync`) and signal. Defaults to the last 30 days.
      x-sdk-resource: usage
      x-sdk-method: get
      parameters:
        - name: from
          in: query
          description: First day, `YYYY-MM-DD`. Defaults to 29 days before `to`.
          schema: { type: string, format: date }
        - name: to
          in: query
          description: Last day, `YYYY-MM-DD`. Defaults to today.
          schema: { type: string, format: date }
      responses:
        "200":
          description: Usage for the range.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Usage" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

webhooks:
  analysis.completed:
    post:
      operationId: onAnalysisCompleted
      summary: Analysis completed
      description: |
        Sent to every enabled endpoint subscribed to `analysis.completed`
        (whose `signal_keys` filter matches) each time an asynchronous analysis
        finishes. Verify
        `X-Webhook-Signature` before trusting the body.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AnalysisCompletedEvent" }
      responses:
        "200":
          description: Any `2xx` acknowledges the delivery. `429` and `5xx` are retried; other `4xx` are not.
  rule.triggered:
    post:
      operationId: onRuleTriggered
      summary: Rule triggered
      description: |
        Sent to the endpoint a `webhook` rule action references when the rule
        fires. The endpoint must be enabled and subscribed to `rule.triggered`;
        otherwise the firing is recorded with status `error`.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RuleTriggeredEvent" }
      responses:
        "200":
          description: Any `2xx` acknowledges the delivery.

components:
  securitySchemes:
    ApiKeyId:
      type: apiKey
      in: query
      name: api_key
      description: |
        The public key ID. It identifies the key but grants nothing
        on its own: the request must also carry a JWT signed with the key's
        secret. It may instead be sent as the JWT header `kid`.
    SignedJWT:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        An HS256 JWT signed with the API key's secret, sent as
        `Authorization: Bearer <jwt>`.

        | Claim | Required | Meaning |
        |-------|----------|---------|
        | `iat` | yes | Issued at, Unix seconds |
        | `exp` | yes | Expiry, at most 300 s after `iat` |
        | `jti` | no  | A unique token ID |
        | `htm` | no  | Binds the token to one HTTP method, e.g. `POST` |
        | `htu` | no  | Binds the token to one path, e.g. `/v1/objects/user-42` |

        ±60 s of clock skew is allowed. Any failure is a generic `401`.
    ConsoleToken:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        A console token minted by `POST /auth/console/login`, used by the
        SigWise console on behalf of a signed-in user. Requests with a console
        token carry no `api_key`.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        A unique string (such as a UUID) that makes the request safe to retry:
        repeating it with the same key records nothing again. At most 255
        characters; remembered for 24 hours.
      schema: { type: string, maxLength: 255 }
      example: 6f1c0e3e-5b8a-4c7e-9f2d-1a2b3c4d5e6f
    ObjectId:
      name: object_id
      in: path
      required: true
      description: Your identifier for the object, e.g. `user-42`.
      schema: { type: string }
      example: user-42
    SignalKey:
      name: key
      in: path
      required: true
      description: The signal's key, e.g. `is_scammer`.
      schema: { type: string, pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$" }
      example: is_scammer
    EndpointId:
      name: endpoint_id
      in: path
      required: true
      schema: { type: string, format: uuid }
    DeliveryId:
      name: delivery_id
      in: path
      required: true
      schema: { type: string, format: uuid }
    RuleId:
      name: rule_id
      in: path
      required: true
      schema: { type: string, format: uuid }
    KeyId:
      name: key_id
      in: path
      required: true
      description: The key's `id` (a UUID), not its public `your_key_id` key ID.
      schema: { type: string, format: uuid }
    Provider:
      name: provider
      in: path
      required: true
      schema: { type: string, enum: [google, github] }
    WebhookSignature:
      name: X-Webhook-Signature
      in: header
      required: true
      description: '`sha256=<hex>`: HMAC-SHA256 of `"<timestamp>.<raw body>"` keyed with the endpoint secret.'
      schema: { type: string }
    WebhookTimestamp:
      name: X-Webhook-Timestamp
      in: header
      required: true
      description: Unix seconds when the attempt was signed. Reject stale values to prevent replay.
      schema: { type: string }
    WebhookEvent:
      name: X-Webhook-Event
      in: header
      required: true
      schema: { type: string, enum: [analysis.completed, rule.triggered] }
    WebhookDelivery:
      name: X-Webhook-Delivery
      in: header
      description: The delivery ID. Retries of one delivery share it, so use it to deduplicate.
      schema: { type: string, format: uuid }

  responses:
    BadRequest:
      description: The request is malformed or fails validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: bad_request, message: at least one event is required } }
    Unauthorized:
      description: The credential is missing, invalid, expired or revoked.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: unauthorized, message: unauthorized } }
    PaymentRequired:
      description: The tenant's prepaid balance is empty.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: payment_required, message: insufficient balance } }
    Forbidden:
      description: The credential is valid but cannot perform this action.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: forbidden, message: this is a console-user action } }
    NotFound:
      description: The resource does not exist.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: not_found, message: signal not found } }
    IdempotencyKeyReused:
      description: The `Idempotency-Key` was already used for a different object.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: idempotency_key_reused, message: idempotency key already used for a different object } }
    Conflict:
      description: The request conflicts with the resource's current state.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: conflict, message: signal is disabled; enable it before re-analyzing } }
    TooManyRequests:
      description: Too many requests or attempts; retry after the `Retry-After` delay.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: rate_limited, message: too many attempts; try again later } }
    AnalyzerBusy:
      description: The analyzer is throttling requests. The events were recorded; retry the verdict after the `Retry-After` delay.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: analyzer_busy, message: analyzer is busy; retry after the Retry-After delay } }
    BadGateway:
      description: An upstream service (the analyzer, Stripe, an OAuth provider) failed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: internal_error, message: analysis failed } }
    ServiceUnavailable:
      description: The feature is not configured on this deployment.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: { code: payments_not_configured, message: self-serve payments are not configured } }

  schemas:
    # --------------------------------------------------------------- common
    Error:
      type: object
      required: [error]
      properties:
        error:
          $ref: "#/components/schemas/ErrorBody"
    ErrorBody:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          description: |
            A stable, machine-readable code: `bad_request`, `unauthorized`,
            `forbidden`, `not_found`, `conflict`, `payment_required`,
            `rate_limited`, `payments_not_configured`,
            `payment_provider_error`, `internal_error`, `not_ready`, or a
            console-specific code such as `weak_password`.
          examples: [not_found]
        message:
          type: string
          description: A human-readable explanation. Do not match on it; it may change.
          examples: [signal not found]
    Health:
      type: object
      required: [status]
      properties:
        status: { type: string, examples: [ok] }
    Message:
      type: object
      required: [message]
      properties:
        message: { type: string }
    StripeWebhookAck:
      type: object
      required: [received]
      properties:
        received: { type: boolean }
    Metadata:
      type: object
      description: Arbitrary structured attributes.
      additionalProperties: true

    # --------------------------------------------------------------- events
    EventType:
      type: string
      enum: [event, message]
      description: "`event` is a discrete action (e.g. `profile.updated`); `message` is free-form text."
    EventInput:
      type: object
      required: [type]
      properties:
        type: { $ref: "#/components/schemas/EventType" }
        name:
          type: string
          description: The event name, or a message's subject.
          examples: [profile.updated]
        content:
          type: string
          description: Free text, such as a message body.
        metadata: { $ref: "#/components/schemas/Metadata" }
        occurred_at:
          type: string
          format: date-time
          description: When it happened. Defaults to the time the API received it.
    IngestRequest:
      type: object
      required: [events]
      properties:
        object_type:
          type: string
          description: Optional classification, e.g. `user` or `listing`.
          examples: [user]
        events:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/EventInput" }
        wait:
          type: boolean
          default: false
          description: Score the object inline and return the verdict (`200`) instead of scheduling analysis (`202`).
        signals:
          type: array
          items: { type: string }
          description: With `wait`, score only these signal keys. Ignored otherwise.
        include_history:
          type: boolean
          default: true
          description: With `wait`, whether to score over the object's prior history (`true`) or only this request's events (`false`).
    IngestAccepted:
      type: object
      required: [object_id, accepted, analysis_status, analysis_delay_ms]
      properties:
        object_id: { type: string }
        accepted:
          type: integer
          description: How many events were recorded.
        analysis_status:
          type: string
          enum: [scheduled]
        analysis_delay_ms:
          type: integer
          description: The debounce window before the analysis runs.
    Answer:
      type: object
      description: One signal's answer. Only the fields for its `type` are set.
      required: [signal, type]
      properties:
        signal: { type: string, examples: [is_scammer] }
        type: { $ref: "#/components/schemas/SignalType" }
        noul:
          type: number
          minimum: 0
          maximum: 1
          description: "`noul`: the probability the answer is yes."
        choice:
          type: string
          description: "`choice`: the most likely option."
        score:
          type: number
          description: "`score`: the position on the spectrum, from 0 (the first level) to the number of levels minus one."
        probabilities:
          type: object
          additionalProperties: { type: number }
          description: "`choice` and `score`: the probability of each option or level."
        confidence:
          type: number
          description: "`choice` and `score`: the analyzer's confidence."
    ModerationVerdict:
      type: object
      required: [object_id, accepted, analyzed, history_included, model, latency_ms, answers]
      properties:
        object_id: { type: string }
        accepted:
          type: integer
          description: How many events were recorded.
        analyzed:
          type: boolean
          description: False when no configured signal matched, so nothing was scored.
        history_included: { type: boolean }
        model:
          type: string
          description: The model that produced the answers, e.g. `model-1`.
        latency_ms: { type: integer }
        answers:
          type: array
          items: { $ref: "#/components/schemas/Answer" }
        reason:
          type: string
          description: Why nothing was analyzed, when `analyzed` is false.
    Event:
      type: object
      required: [type, occurred_at]
      properties:
        type: { $ref: "#/components/schemas/EventType" }
        name: { type: string }
        content: { type: string }
        metadata: { $ref: "#/components/schemas/Metadata" }
        occurred_at: { type: string, format: date-time }
    EventList:
      type: object
      required: [events]
      properties:
        events:
          type: array
          items: { $ref: "#/components/schemas/Event" }

    # --------------------------------------------------------------- objects
    SignalResult:
      type: object
      description: The latest answer for one signal. Only the fields for its `type` are set.
      required: [key, type, computed_at]
      properties:
        key: { type: string, examples: [trust_score] }
        type: { $ref: "#/components/schemas/SignalType" }
        noul: { type: number, minimum: 0, maximum: 1 }
        choice: { type: string }
        score: { type: number }
        probabilities:
          type: object
          additionalProperties: { type: number }
        confidence: { type: number }
        model: { type: string }
        computed_at: { type: string, format: date-time }
    ObjectAnalysis:
      type: object
      required: [object_id, event_count, analysis, pending]
      properties:
        object_id: { type: string, examples: [user-42] }
        event_count: { type: integer }
        analysis:
          type: array
          items: { $ref: "#/components/schemas/SignalResult" }
        pending:
          type: array
          items: { type: string }
          description: Enabled signals with no answer yet.
    ObjectSummary:
      type: object
      required: [object_id, object_type, event_count, last_seen, analysis, pending]
      properties:
        object_id: { type: string }
        object_type: { type: string }
        display_name: { type: string }
        event_count: { type: integer }
        last_seen: { type: string, format: date-time }
        analysis:
          type: array
          items: { $ref: "#/components/schemas/SignalResult" }
        pending:
          type: array
          items: { type: string }
    ObjectList:
      type: object
      required: [objects, limit, offset]
      properties:
        objects:
          type: array
          items: { $ref: "#/components/schemas/ObjectSummary" }
        limit: { type: integer }
        offset: { type: integer }
        next_cursor:
          type: string
          description: Pass as `cursor` to fetch the next page. Absent on the last page and for signal queries.
    CompactedState:
      type: object
      description: A rolling summary of an object's older events.
      required: [total_events, messages, actions, first_seen, updated_at]
      properties:
        total_events: { type: integer }
        messages: { type: integer }
        actions: { type: integer }
        by_action:
          type: object
          additionalProperties: { type: integer }
        sample_messages:
          type: array
          items: { type: string }
        first_seen: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    ObjectState:
      type: object
      required: [object_id, compacted_events]
      properties:
        object_id: { type: string }
        compacted_events:
          type: integer
          description: How many events have been folded into the summary.
        compacted_through:
          type: [string, "null"]
          format: date-time
        compacted_state:
          oneOf:
            - $ref: "#/components/schemas/CompactedState"
            - type: "null"
        last_analyzed_at:
          type: [string, "null"]
          format: date-time
    AnalyzeScheduled:
      type: object
      required: [object_id, analysis_status]
      properties:
        object_id: { type: string }
        analysis_status: { type: string, enum: [scheduled] }
    AnalyzeAllScheduled:
      type: object
      required: [scheduled, analysis_status]
      properties:
        scheduled:
          type: integer
          description: How many objects were scheduled.
        analysis_status: { type: string, enum: [scheduled] }
    Overview:
      type: object
      required: [objects, signals, analyzed_pct, events_today, flagged, signal_summary, categories, top_risk]
      properties:
        objects: { type: integer }
        signals: { type: integer }
        analyzed_pct:
          type: number
          description: Share of objects with at least one answer, 0–1.
        events_today:
          type: integer
          description: Events recorded in the last 24 hours.
        flagged: { type: integer }
        signal_summary:
          type: array
          items: { $ref: "#/components/schemas/SignalSummary" }
        categories:
          type: array
          items: { $ref: "#/components/schemas/CategoryCount" }
        top_risk:
          type: array
          items: { $ref: "#/components/schemas/RiskyObject" }
    CategoryCount:
      type: object
      description: How many objects have one `object_type`.
      required: [name, count]
      properties:
        name: { type: string }
        count: { type: integer }
    RiskyObject:
      type: object
      description: One of the objects with the highest yes/no probabilities.
      required: [object_id, risk]
      properties:
        object_id: { type: string }
        display_name: { type: string }
        risk: { type: number }
    SignalSummary:
      type: object
      required: [key, type, total]
      properties:
        key: { type: string }
        type: { $ref: "#/components/schemas/SignalType" }
        total:
          type: integer
          description: Objects with an answer for the signal.
        avg: { type: number }
        high:
          type: integer
          description: Objects answered high (yes/no signals).
        high_pct: { type: number }
        choices:
          type: object
          additionalProperties: { type: number }
          description: "`choice` signals: the share of each option."

    # --------------------------------------------------------------- signals
    SignalType:
      type: string
      enum: [noul, choice, score]
      description: "`noul`: yes/no probability. `score`: position on a labelled spectrum. `choice`: one of N options."
    SignalInput:
      type: object
      required: [type, instructions]
      properties:
        type: { $ref: "#/components/schemas/SignalType" }
        instructions:
          description: What the analyzer should decide. Usually a string; objects and arrays are passed through verbatim.
          oneOf:
            - type: string
            - type: object
            - type: array
        criteria:
          description: Depends on `type`; see the operation description.
          oneOf:
            - type: object
            - type: array
        enabled:
          type: boolean
          default: true
    Signal:
      type: object
      required: [key, type, instructions, enabled, created_at, updated_at]
      properties:
        key: { type: string, examples: [is_scammer] }
        type: { $ref: "#/components/schemas/SignalType" }
        instructions:
          oneOf:
            - type: string
            - type: object
            - type: array
        criteria:
          oneOf:
            - type: object
            - type: array
        enabled: { type: boolean }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        backfill:
          $ref: "#/components/schemas/SignalBackfill"
          description: Set only when creating the signal started a backfill (`auto_backfill_signals`).
    SignalList:
      type: object
      required: [signals]
      properties:
        signals:
          type: array
          items: { $ref: "#/components/schemas/Signal" }
    SignalBackfill:
      type: object
      required: [signal_key, status, total, completed, pending, started_at]
      properties:
        signal_key: { type: string }
        status: { type: string, enum: [running, done] }
        total:
          type: integer
          description: Objects enqueued when the backfill started.
        completed:
          type: integer
          description: Objects answered since it started.
        pending:
          type: integer
          description: Objects still queued or running.
        started_at: { type: string, format: date-time }
    BackfillEstimate:
      type: object
      required: [objects, estimated_cost_micros]
      properties:
        objects:
          type: integer
          description: Objects with no answer for the signal.
        estimated_cost_micros:
          type: integer
          description: Estimated cost in micro-dollars (1e-6 USD), an upper bound. 0 without paid history.
    BackfillStatus:
      type: object
      required: [estimate, backfill]
      properties:
        estimate: { $ref: "#/components/schemas/BackfillEstimate" }
        backfill:
          oneOf:
            - $ref: "#/components/schemas/SignalBackfill"
            - type: "null"

    # --------------------------------------------------------------- settings
    Settings:
      type: object
      required: [auto_backfill_signals]
      properties:
        auto_backfill_signals:
          type: boolean
          description: Creating a signal automatically backfills existing objects for it. Off by default because each analysis is billed.
    SettingsUpdate:
      type: object
      properties:
        auto_backfill_signals: { type: boolean }

    # --------------------------------------------------------------- webhooks
    WebhookEventType:
      type: string
      enum: [analysis.completed, rule.triggered]
    WebhookEndpoint:
      type: object
      required: [id, url, description, enabled, signal_keys, events, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        url: { type: string, format: uri }
        description: { type: string }
        enabled: { type: boolean }
        signal_keys:
          type: array
          items: { type: string }
          description: Only deliver analyses that answered one of these signals. Empty means every analysis.
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventType" }
          description: The event types this endpoint receives.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    WebhookEndpointCreate:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: An `http` or `https` URL.
          examples: ["https://example.com/hooks/analyze"]
        description: { type: string }
        signal_keys:
          type: array
          items: { type: string }
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items: { $ref: "#/components/schemas/WebhookEventType" }
          description: |
            The event types to deliver. Omit to receive every event type. Use
            `["rule.triggered"]` for an endpoint that only receives rule firings.
        enabled: { type: boolean, default: true }
    WebhookEndpointUpdate:
      type: object
      properties:
        url: { type: string, format: uri }
        description: { type: string }
        signal_keys:
          type: array
          items: { type: string }
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items: { $ref: "#/components/schemas/WebhookEventType" }
        enabled: { type: boolean }
    WebhookEndpointCreated:
      type: object
      required: [endpoint, secret, note]
      properties:
        endpoint: { $ref: "#/components/schemas/WebhookEndpoint" }
        secret:
          type: string
          description: The signing secret. Shown only once.
        note: { type: string }
    WebhookEndpointList:
      type: object
      required: [webhooks]
      properties:
        webhooks:
          type: array
          items: { $ref: "#/components/schemas/WebhookEndpoint" }
    DeliveryStatus:
      type: string
      enum: [pending, delivering, delivered, dead]
      description: "`dead` deliveries are in the dead-letter queue: permanently rejected, or out of retries."
    WebhookDelivery:
      type: object
      required: [id, endpoint_id, event_type, status, attempts, max_attempts, run_after, created_at]
      properties:
        id: { type: string, format: uuid }
        endpoint_id: { type: string, format: uuid }
        event_type: { type: string, enum: [analysis.completed, rule.triggered] }
        object_id: { type: string }
        status: { $ref: "#/components/schemas/DeliveryStatus" }
        attempts: { type: integer }
        max_attempts: { type: integer }
        last_status_code:
          type: integer
          description: The endpoint's last HTTP status, if it answered.
        last_error: { type: string }
        run_after:
          type: string
          format: date-time
          description: When the next attempt is due.
        delivered_at: { type: string, format: date-time }
        created_at: { type: string, format: date-time }
        payload:
          type: object
          additionalProperties: true
          description: The body that is (or was) delivered.
    WebhookDeliveryList:
      type: object
      required: [deliveries]
      properties:
        deliveries:
          type: array
          items: { $ref: "#/components/schemas/WebhookDelivery" }
    AnalysisCompletedEvent:
      type: object
      required: [event, object_id, tenant_id, model, occurred_at, answers]
      properties:
        event: { type: string, const: analysis.completed }
        object_id: { type: string }
        tenant_id: { type: string, format: uuid }
        model: { type: string }
        occurred_at: { type: string, format: date-time }
        answers:
          type: array
          items: { $ref: "#/components/schemas/Answer" }
    RuleTriggeredEvent:
      type: object
      required: [event, rule_id, tenant_id, object_id, matched_when, occurred_at, values]
      properties:
        event: { type: string, const: rule.triggered }
        rule_id: { type: string, format: uuid }
        rule_name: { type: string }
        tenant_id: { type: string, format: uuid }
        object_id: { type: string }
        matched_when: { type: string }
        model: { type: string }
        occurred_at: { type: string, format: date-time }
        values:
          type: array
          items: { $ref: "#/components/schemas/Answer" }

    # --------------------------------------------------------------- rules
    ActionType:
      type: string
      enum: [email, slack, webhook]
    ActionConfig:
      type: object
      additionalProperties: true
      description: |
        Depends on `action_type`:

        - `email`: `{"to": "abuse@acme.com", "subject": "…"}` (`subject` optional)
        - `slack`: `{"webhook_url": "https://hooks.slack.com/…"}`
        - `webhook`: `{"endpoint_id": "<registered endpoint id>"}`
    Rule:
      type: object
      required: [id, name, when, action_type, action_config, enabled, cooldown_seconds, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        when:
          type: string
          description: The condition, e.g. `is_scammer >= 90 and trust_score < 1`.
        action_type: { $ref: "#/components/schemas/ActionType" }
        action_config: { $ref: "#/components/schemas/ActionConfig" }
        enabled: { type: boolean }
        cooldown_seconds:
          type: integer
          description: 0 fires only when the condition turns true; more lets the rule re-fire after that long while it stays true.
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
    RuleCreate:
      type: object
      required: [when, action_type, action_config]
      properties:
        name: { type: string }
        when: { type: string }
        action_type: { $ref: "#/components/schemas/ActionType" }
        action_config: { $ref: "#/components/schemas/ActionConfig" }
        enabled: { type: boolean, default: true }
        cooldown_seconds: { type: integer, minimum: 0, default: 0 }
    RuleUpdate:
      type: object
      properties:
        name: { type: string }
        when: { type: string }
        action_type: { $ref: "#/components/schemas/ActionType" }
        action_config: { $ref: "#/components/schemas/ActionConfig" }
        enabled: { type: boolean }
        cooldown_seconds: { type: integer, minimum: 0 }
    RuleList:
      type: object
      required: [rules]
      properties:
        rules:
          type: array
          items: { $ref: "#/components/schemas/Rule" }
    RuleFiring:
      type: object
      required: [id, rule_id, object_id, matched_when, action_type, status, fired_at]
      properties:
        id: { type: string, format: uuid }
        rule_id: { type: string, format: uuid }
        object_id: { type: string }
        matched_when: { type: string }
        signal_values:
          type: object
          additionalProperties: true
          description: The signal values that satisfied the condition.
        action_type: { $ref: "#/components/schemas/ActionType" }
        status: { type: string, enum: [dispatched, error] }
        detail:
          type: string
          description: The error, or a short success note.
        fired_at: { type: string, format: date-time }
    RuleFiringList:
      type: object
      required: [firings]
      properties:
        firings:
          type: array
          items: { $ref: "#/components/schemas/RuleFiring" }

    # --------------------------------------------------------------- api keys
    APIKeyStatus:
      type: string
      enum: [active, inactive, revoked]
    APIKey:
      type: object
      required: [id, key, name, status, has_secret, created_at]
      properties:
        id: { type: string, format: uuid }
        key:
          type: string
          description: The public key ID, sent as `api_key`.
          examples: [7Hx2Qp9LmZ]
        name: { type: string }
        status: { $ref: "#/components/schemas/APIKeyStatus" }
        has_secret:
          type: boolean
          description: Whether the key can sign requests. Legacy keys can't until rotated.
        legacy:
          type: boolean
          description: Created before request signing; rotate it to get a signing secret.
        legacy_preview: { type: string }
        created_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time }
        legacy_used_at: { type: string, format: date-time }
    APIKeyCreate:
      type: object
      properties:
        name: { type: string, examples: [Production] }
    APIKeyUpdate:
      type: object
      properties:
        name: { type: string }
        status:
          type: string
          enum: [active, inactive]
    APIKeySecret:
      type: object
      required: [api_key, key, secret, note]
      properties:
        api_key: { $ref: "#/components/schemas/APIKey" }
        key:
          type: string
          description: The public key ID.
        secret:
          type: string
          description: The signing secret. Shown only once.
        note: { type: string }
    APIKeyList:
      type: object
      required: [api_keys]
      properties:
        api_keys:
          type: array
          items: { $ref: "#/components/schemas/APIKey" }

    # --------------------------------------------------------------- billing
    Balance:
      type: object
      required: [balance_cents, currency, metering_enabled, enforcement, self_serve_topup, low_balance_threshold_cents]
      properties:
        balance_cents: { type: integer }
        currency: { type: string, examples: [usd] }
        metering_enabled:
          type: boolean
          description: Whether analyses are billed on this deployment.
        enforcement:
          type: string
          description: "`block` pauses analyses at an empty balance; `grace` lets it go negative."
        self_serve_topup:
          type: boolean
          description: Whether card top-ups are available.
        low_balance_threshold_cents: { type: integer }
        topup_min_cents: { type: integer }
        topup_max_cents: { type: integer }
    LedgerEntry:
      type: object
      required: [id, delta_cents, balance_after_cents, delta_micros, balance_after_micros, reason, created_at]
      properties:
        id: { type: string, format: uuid }
        delta_cents:
          type: integer
          description: Negative for a charge, positive for a credit. Sub-cent charges round to 0; see `delta_micros`.
        balance_after_cents: { type: integer }
        delta_micros:
          type: integer
          description: The exact amount in micro-dollars (1e-6 USD).
        balance_after_micros: { type: integer }
        reason: { type: string }
        model: { type: string }
        object_id: { type: string }
        cost_usd: { type: number }
        cost_margin: { type: number }
        input_tokens: { type: integer }
        output_tokens: { type: integer }
        payment_ref:
          type: string
          description: The Stripe Checkout Session that paid for a top-up.
        created_at: { type: string, format: date-time }
    LedgerPage:
      type: object
      required: [entries, next_cursor]
      properties:
        entries:
          type: array
          items: { $ref: "#/components/schemas/LedgerEntry" }
        next_cursor:
          type: string
          description: Pass as `cursor` for the next page. Empty on the last page.
    CheckoutCreate:
      type: object
      required: [amount_cents]
      properties:
        amount_cents:
          type: integer
          description: Within the deployment's `topup_min_cents`–`topup_max_cents`.
          examples: [2500]
    CheckoutSession:
      type: object
      required: [id, url]
      properties:
        id: { type: string, examples: [cs_test_a1b2c3] }
        url:
          type: string
          format: uri
          description: Send the customer here to pay.
    CheckoutStatus:
      type: object
      required: [id, status]
      properties:
        id: { type: string }
        status: { type: string, enum: [pending, credited] }
        amount_cents: { type: integer }
        balance_after_cents: { type: integer }

    # --------------------------------------------------------------- usage
    Usage:
      type: object
      required: [from, to, currency, metering_enabled, balance_cents, burn_rate_micros_per_day, totals, series, by_model, by_source, by_signal]
      properties:
        from: { type: string, format: date }
        to: { type: string, format: date }
        currency: { type: string }
        metering_enabled: { type: boolean }
        balance_cents: { type: integer }
        burn_rate_micros_per_day:
          type: integer
          description: Average spend per elapsed day of the range, in micro-dollars.
        runway_days:
          type: number
          description: Balance divided by burn rate. Omitted without spend or balance.
        totals: { $ref: "#/components/schemas/UsageTotals" }
        series:
          type: array
          items: { $ref: "#/components/schemas/UsageDay" }
        by_model:
          type: array
          items: { $ref: "#/components/schemas/UsageBreakdown" }
        by_source:
          type: array
          items: { $ref: "#/components/schemas/UsageBreakdown" }
        by_signal:
          type: array
          items: { $ref: "#/components/schemas/UsageSignal" }
    UsageTotals:
      type: object
      required: [analyses, spend_micros, input_tokens, output_tokens]
      properties:
        analyses: { type: integer }
        spend_micros: { type: integer }
        input_tokens: { type: integer }
        output_tokens: { type: integer }
    UsageDay:
      type: object
      required: [date, analyses, async, sync, spend_micros]
      properties:
        date: { type: string, format: date }
        analyses: { type: integer }
        async: { type: integer }
        sync: { type: integer }
        spend_micros: { type: integer }
    UsageBreakdown:
      type: object
      required: [key, analyses, spend_micros, input_tokens, output_tokens]
      properties:
        key: { type: string }
        analyses: { type: integer }
        spend_micros: { type: integer }
        input_tokens: { type: integer }
        output_tokens: { type: integer }
    UsageSignal:
      type: object
      required: [signal, analyses]
      properties:
        signal: { type: string }
        analyses: { type: integer }

    # --------------------------------------------------------------- account
    Me:
      type: object
      required: [auth_type, tenant_id, tenant_name]
      properties:
        auth_type:
          type: string
          enum: [tenant, console]
          description: "`tenant` for API keys, `console` for a signed-in user."
        tenant_id: { type: string, format: uuid }
        tenant_name: { type: string }
        user_id: { type: string, format: uuid }
        email: { type: string, format: email }
        role: { type: string }
        onboarded: { type: boolean }

    # --------------------------------------------------------------- console
    Token:
      type: object
      required: [token, expires_at, token_type]
      properties:
        token: { type: string }
        expires_at: { type: string, format: date-time }
        token_type: { type: string, enum: [console, tenant] }
        new_user:
          type: boolean
          description: Social sign-in only; true when it created the account.
    LoginRequest:
      type: object
      required: [email, password]
      properties:
        email: { type: string, format: email }
        password: { type: string, format: password }
        code:
          type: string
          description: An authenticator code or a recovery code. Required only when two-factor authentication is on.
          example: "492039"
    RegisterRequest:
      type: object
      required: [tenant_name, email, password]
      properties:
        tenant_name: { type: string }
        email: { type: string, format: email }
        password: { type: string, format: password, minLength: 8 }
    TokenExchangeRequest:
      type: object
      required: [api_key, secret]
      properties:
        api_key: { type: string }
        secret: { type: string }
    ForgotPasswordRequest:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
    ResetPasswordRequest:
      type: object
      required: [token, password]
      properties:
        token: { type: string }
        password: { type: string, format: password, minLength: 8 }
    MfaStatus:
      type: object
      required: [enabled, recovery_codes_remaining]
      properties:
        enabled: { type: boolean }
        recovery_codes_remaining: { type: integer, description: Unused recovery codes; 0 while two-factor is off. }
    MfaSetup:
      type: object
      required: [secret, otpauth_uri]
      properties:
        secret: { type: string, description: "The shared secret in base32, for typing into an authenticator.", example: JBSWY3DPEHPK3PXP }
        otpauth_uri: { type: string, description: "The same secret as an `otpauth://` URI, to render as a QR code." }
    MfaEnableRequest:
      type: object
      required: [code]
      properties:
        code: { type: string, description: A current code from the authenticator., example: "492039" }
    MfaRecoveryCodes:
      type: object
      required: [recovery_codes]
      properties:
        recovery_codes:
          type: array
          description: Shown once. Store them somewhere safe.
          items: { type: string, example: K7M2Q-9XW4B }
    MfaDisableRequest:
      type: object
      required: [code]
      properties:
        password: { type: string, format: password, description: Required unless the account has no password (social sign-in only). }
        code: { type: string, description: A current authenticator code or an unused recovery code. }
    ChangePasswordRequest:
      type: object
      required: [current_password, new_password]
      properties:
        current_password: { type: string, format: password }
        new_password: { type: string, format: password, minLength: 8 }
    OAuthProviders:
      type: object
      required: [providers, allow_registration]
      properties:
        providers:
          type: array
          items: { type: string, enum: [google, github] }
        allow_registration: { type: boolean }
    OAuthAuthorizeRequest:
      type: object
      required: [state, code_challenge]
      properties:
        state:
          type: string
          minLength: 32
        code_challenge:
          type: string
          minLength: 43
          maxLength: 43
          description: Base64url SHA-256 of the PKCE verifier (S256).
    AuthorizationURL:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri }
    OAuthCodeRequest:
      type: object
      required: [code, code_verifier]
      properties:
        code: { type: string }
        code_verifier: { type: string }
        tenant_name:
          type: string
          description: Used only when the sign-in creates a new account.
    Identity:
      type: object
      required: [provider, email, created_at]
      properties:
        provider: { type: string, enum: [google, github] }
        email: { type: string, format: email }
        created_at: { type: string, format: date-time }
    Identities:
      type: object
      required: [has_password, identities, providers]
      properties:
        has_password: { type: boolean }
        identities:
          type: array
          items: { $ref: "#/components/schemas/Identity" }
        providers:
          type: array
          items: { type: string, enum: [google, github] }
    NotificationPreference:
      type: object
      required: [event, label, description, enabled, critical]
      properties:
        event: { type: string, examples: [low_balance] }
        label: { type: string }
        description: { type: string }
        enabled: { type: boolean }
        critical:
          type: boolean
          description: Always sent; can't be turned off.
    NotificationPreferences:
      type: object
      required: [preferences]
      properties:
        preferences:
          type: array
          items: { $ref: "#/components/schemas/NotificationPreference" }
    NotificationPreferencesUpdate:
      type: object
      additionalProperties: { type: boolean }
      examples:
        - { low_balance: false }
