openapi: 3.1.0
info:
  title: Tickerbot API
  version: '2.0.0'
  summary: The US stock market and top 100 crypto as a SQL-queryable surface.
  description: |
    Tickerbot is a developer-facing API over a continuously-updated stock
    universe. ~12,000 US-listed equities plus the top 100 cryptocurrencies
    by market cap. Every column on a ticker (price, volume, indicators,
    fundamentals, and 100+ pre-computed boolean signal flags) is queryable
    via a SQL WHERE clause and pullable as a time series at minute, hourly,
    daily, or weekly resolution.

    All responses carry a top-level `as_of` ISO timestamp indicating data
    vintage. List and history responses use opaque cursor pagination —
    treat `next_cursor` as a black-box token and pass it back via
    `?cursor=` on the next request.
  contact:
    name: Tickerbot Support
    url: https://tickerbot.io/support
    email: support@tickerbot.io
  license:
    name: Proprietary
    url: https://tickerbot.io/terms

servers:
  - url: https://api.tickerbot.io
    description: Production

# ─────────────────────────────────────────────────────────────────────────
# Auth
# ─────────────────────────────────────────────────────────────────────────
security:
  - apiBearer: []

# ─────────────────────────────────────────────────────────────────────────
# Tags
# ─────────────────────────────────────────────────────────────────────────
tags:
  - name: Tickers
    description: Current state, historical state, and event logs for individual tickers and the universe.
  - name: Signals
    description: Match endpoint ("who fits this signal now") and per-ticker time series.
  - name: Scan
    description: Universe-wide SQL queries, live and as-of-date.
  - name: Webhooks
    description: SQL-driven subscriptions with HMAC-signed delivery and retry semantics.
  - name: Rules
    description: Named, reusable saved scans referenced by webhooks and /scan?rule=.
  - name: Universes
    description: Named ticker lists — customer-defined and system-managed (top_10, top_100).
  - name: Meta
    description: Health and version probes.

# ─────────────────────────────────────────────────────────────────────────
# Paths
# ─────────────────────────────────────────────────────────────────────────
paths:

  /v2/health:
    get:
      tags: [Meta]
      summary: Liveness probe
      description: Returns 200 with a tiny payload. No auth required. Does not consume rate-limit quota.
      operationId: getHealth
      security: []
      responses:
        '200':
          description: Service is up.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResponse'

  /v2/version:
    get:
      tags: [Meta]
      summary: Service version
      description: Identifies the running build. No auth required.
      operationId: getVersion
      security: []
      responses:
        '200':
          description: Service identity.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VersionResponse'

  /v2/tickers:
    get:
      tags: [Tickers]
      summary: List or bulk-fetch tickers
      description: |
        Without `?tickers=`: paginated alphabetical walk through the active
        universe, returning a slim payload (ticker, name, asset_type, exchange,
        market_cap). With `?tickers=NVDA,TSLA,JPM`: bulk lookup returning the
        full row for each requested symbol (pagination params ignored).
      operationId: listTickers
      parameters:
        - $ref: '#/components/parameters/TickersList'
        - $ref: '#/components/parameters/LimitTickers'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Page of tickers (list mode) or bulk results.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/TickersListResponse'
                  - $ref: '#/components/schemas/TickersBulkResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v2/tickers/{ticker}:
    parameters:
      - $ref: '#/components/parameters/TickerPath'
    get:
      tags: [Tickers]
      summary: Get the full current state of one ticker
      description: Returns every column on the ticker row, including every numeric signal and every boolean flag at their current values.
      operationId: getTicker
      responses:
        '200':
          description: The full ticker row.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TickerSingleResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v2/tickers/{ticker}/history:
    parameters:
      - $ref: '#/components/parameters/TickerPath'
    get:
      tags: [Tickers]
      summary: Get the full ticker row as of a past date
      description: |
        Returns the wide ticker row as it stood on the requested date —
        indicators, flag values, and most-recent fundamentals known on that
        date. History depth is plan-gated (7d Free, 30d Hobby, 180d Pro,
        365d Scale, unlimited Enterprise).
      operationId: getTickerHistory
      parameters:
        - $ref: '#/components/parameters/AsOfRequired'
      responses:
        '200':
          description: Wide row at the requested date.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TickerHistoryResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v2/tickers/{ticker}/events:
    parameters:
      - $ref: '#/components/parameters/TickerPath'
    get:
      tags: [Tickers]
      summary: Discrete event log (splits, dividends, ratings)
      description: Returns splits, dividends, and analyst rating changes for the ticker, newest-first.
      operationId: listTickerEvents
      parameters:
        - { name: from,   in: query, required: false, schema: { type: string, example: '2026-01-01' }, description: 'Earliest event timestamp (inclusive).' }
        - { name: to,     in: query, required: false, schema: { type: string, example: '2026-05-14' }, description: 'Latest event timestamp (inclusive).' }
        - { name: kind,   in: query, required: false, schema: { type: string, enum: [split, dividend, rating_change] }, description: 'Filter by event kind.' }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Page of events.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventsResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v2/signals/{signal}:
    parameters:
      - { name: signal, in: path, required: true, schema: { type: string, example: rsi_14 }, description: 'A column on the ticker row. Boolean flags are detected automatically; numerics require a ?condition=.' }
    get:
      tags: [Signals]
      summary: Find tickers matching a signal right now
      description: |
        For boolean flags, returns tickers where the flag is `true`. For
        numerics, applies the supplied condition (single-bounded operator +
        literal, e.g. `>70`, `<=200`).
      operationId: matchSignal
      parameters:
        - { name: condition, in: query, required: false, schema: { type: string, example: '>70' }, description: 'Required for numeric signals. Format: `<op><value>` where op is one of >, >=, =, !=, <, <=. Ignored for boolean flags.' }
        - $ref: '#/components/parameters/LimitSmall'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Page of matching tickers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SignalsMatchResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '404':
          description: Signal name does not exist.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /v2/signals/{signal}/{ticker}/history/{interval}:
    parameters:
      - { name: signal,   in: path, required: true, schema: { type: string, example: rsi_14 }, description: 'A column on the wide-state tables.' }
      - $ref: '#/components/parameters/TickerPath'
      - { name: interval, in: path, required: true, schema: { type: string, enum: [1m, 1h, 1d, 1w] }, description: 'Resolution. Daily-only signals return signal_not_available_at_interval if requested at 1m or 1h.' }
    get:
      tags: [Signals]
      summary: Time series for one signal × one ticker × one resolution
      description: |
        Returns a chronological list of {t, v} bars. For numeric signals
        `v` is a number; for boolean flags `v` is true/false at each bar.
        Cursors walk backwards through history.
      operationId: getSignalHistory
      parameters:
        - { name: from, in: query, required: false, schema: { type: string, example: '2026-01-01' }, description: 'Earliest bar (inclusive). YYYY-MM-DD or ISO.' }
        - { name: to,   in: query, required: false, schema: { type: string, example: '2026-05-14' }, description: 'Latest bar (inclusive). YYYY-MM-DD or ISO.' }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 1000, default: 252 } }
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Page of bars.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SignalHistoryResponse'
        '400':
          description: Invalid interval, signal-at-this-interval not stored, or beyond plan history window.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /v2/scan:
    get:
      tags: [Scan]
      summary: SQL WHERE against the live universe
      description: |
        Filter the live ticker universe with a SQL WHERE clause. Column
        names match the customer-facing schema page 1:1. POST /v2/scan
        accepts the same parameters in a JSON body for queries too long
        to fit in a URL.
      operationId: scanLive
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, maxLength: 4000, example: 'above_sma_50 AND day_change_pct > 5' }, description: 'SQL WHERE expression. Semicolons, comments, and write-side keywords rejected.' }
        - { name: order, in: query, required: false, schema: { type: string, pattern: '^[a-z][a-z0-9_]*$', default: 'day_change_pct' } }
        - { name: dir,   in: query, required: false, schema: { type: string, enum: [asc, desc], default: desc } }
        - { name: fields, in: query, required: false, schema: { type: string, example: 'pe_ratio,rsi_14' }, description: 'Comma-separated extra columns to include in each result row.' }
        - $ref: '#/components/parameters/LimitScan'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Page of matching tickers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScanResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
    post:
      tags: [Scan]
      summary: Body-form mirror of GET /v2/scan
      operationId: scanLivePost
      parameters:
        - $ref: '#/components/parameters/Cursor'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [q]
              properties:
                q:      { type: string, maxLength: 4000 }
                order:  { type: string, pattern: '^[a-z][a-z0-9_]*$' }
                dir:    { type: string, enum: [asc, desc] }
                fields: { type: string }
                limit:  { type: integer, minimum: 1, maximum: 100 }
      responses:
        '200':
          description: Same shape as GET /v2/scan.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScanResponse'
        '400': { $ref: '#/components/responses/BadRequest' }

  /v2/scan/history:
    get:
      tags: [Scan]
      summary: SQL WHERE against the universe as of a past date
      description: Same SQL grammar as /v2/scan, evaluated against the historical state of every ticker on the requested date.
      operationId: scanHistory
      parameters:
        - { name: q, in: query, required: true, schema: { type: string, maxLength: 4000 } }
        - $ref: '#/components/parameters/AsOfRequired'
        - { name: order, in: query, required: false, schema: { type: string, pattern: '^[a-z][a-z0-9_]*$', default: 'day_change_pct' } }
        - { name: dir,   in: query, required: false, schema: { type: string, enum: [asc, desc], default: desc } }
        - { name: fields, in: query, required: false, schema: { type: string } }
        - $ref: '#/components/parameters/LimitScan'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Page of historical rows.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScanHistoryResponse'
        '400': { $ref: '#/components/responses/BadRequest' }

  /v2/webhooks:
    get:
      tags: [Webhooks]
      summary: List webhook subscriptions on this API key
      operationId: listWebhooks
      parameters:
        - { name: status, in: query, required: false, schema: { type: string, enum: [active, pending_verification, disabled] } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Page of webhooks (signing_secret stripped).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhooksListResponse'
        '401': { $ref: '#/components/responses/Unauthenticated' }
    post:
      tags: [Webhooks]
      summary: Create a webhook subscription
      description: |
        Registers a SQL WHERE + HTTPS target. We send a one-shot
        `webhook.ping` to verify reachability; on 2xx the subscription
        transitions from `pending_verification` to `active`. Available on
        Pro and above.
      operationId: createWebhook
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, q, target_url]
              properties:
                name:       { type: string, maxLength: 80,  example: 'breakouts-on-tech' }
                q:          { type: string, maxLength: 1500, example: "breakout AND sector = 'Technology'" }
                target_url: { type: string, format: uri, maxLength: 1024, example: 'https://your-app.example.com/webhooks/tickerbot' }
      responses:
        '201':
          description: Webhook created. The response includes the one-time `signing_secret` — save it; it is never returned again.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookFullResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '403':
          description: Webhook tier required (Free/Hobby) or webhook limit reached.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorEnvelope'

  /v2/webhooks/{id}:
    parameters:
      - $ref: '#/components/parameters/WebhookIdPath'
    get:
      tags: [Webhooks]
      summary: Fetch one webhook subscription
      operationId: getWebhook
      responses:
        '200':
          description: The webhook doc (no `signing_secret`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookResponse'
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Webhooks]
      summary: Delete a webhook subscription
      operationId: deleteWebhook
      responses:
        '204': { description: Deleted. }
        '404': { $ref: '#/components/responses/NotFound' }

  /v2/webhooks/{id}/deliveries:
    parameters:
      - $ref: '#/components/parameters/WebhookIdPath'
    get:
      tags: [Webhooks]
      summary: Recent deliveries for one webhook
      operationId: listWebhookDeliveries
      parameters:
        - { name: status, in: query, required: false, schema: { type: string, enum: [pending, delivered, permanent_failure] } }
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Page of delivery attempts.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeliveriesResponse'
        '404': { $ref: '#/components/responses/NotFound' }

  /v2/webhooks/{id}/enable:
    parameters:
      - $ref: '#/components/parameters/WebhookIdPath'
    post:
      tags: [Webhooks]
      summary: Re-enable a disabled webhook
      description: Resets the webhook to `pending_verification` and re-enqueues the handshake ping. The next eval treats every currently-matching ticker as new.
      operationId: enableWebhook
      responses:
        '200':
          description: The updated webhook.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookResponse'
        '404': { $ref: '#/components/responses/NotFound' }

  /v2/rules:
    get:
      tags: [Rules]
      summary: List saved rules
      description: |
        Paginated list of the caller's saved rules, newest-first. Each
        rule is a saved `{q, universe_id?, order, dir, fields}` bundle
        that can be referenced by other surfaces (webhooks, `/scan?rule=`)
        and yields the same matches the inline `q` would have.
      operationId: listRules
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Page of rules.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RuleListResponse'
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '503':
          description: A Firestore index needed for this query is still building.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    post:
      tags: [Rules]
      summary: Create a saved rule
      description: |
        Creates a named, reusable `{q, universe_id?, order, dir, fields}`
        bundle. `id` is optional — when omitted a random `r_xxxxxxxx`
        slug is generated. If supplied, it must match
        `^[a-z][a-z0-9_]{0,62}$` and be unique per-owner. `universe_id`
        is optional; when set it must reference a system universe
        (`top_10`, `top_100`) or one the caller owns. Saved rules are
        available on Hobby and above; the plan cap is enforced on
        creation (Hobby 5, Pro 25, Scale 100, Enterprise unlimited).
      operationId: createRule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RuleCreateRequest'
      responses:
        '201':
          description: Rule created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RuleResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403':
          description: |
            Plan does not include saved rules (`rules_tier_required`),
            the per-plan cap has been reached (`resource_limit_reached`,
            with `current` and `limit` fields), or the rule references a
            premium signal not available on the caller's plan.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '409':
          description: A rule with the supplied `id` already exists for this owner (`slug_taken`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /v2/rules/{id}:
    parameters:
      - $ref: '#/components/parameters/RuleIdPath'
    get:
      tags: [Rules]
      summary: Fetch one saved rule
      operationId: getRule
      responses:
        '200':
          description: The rule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RuleResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Rules]
      summary: Update a saved rule
      description: |
        Partial update — only the fields you send are touched. Pass
        `universe_id: null` (or `""`) to detach the rule from its
        universe. If `q`, `order`, or `fields` change, the premium-signal
        gate is re-evaluated against the resulting bundle.
      operationId: updateRule
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RulePatchRequest'
      responses:
        '200':
          description: The updated rule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RuleResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403':
          description: The updated rule references a premium signal not available on the caller's plan.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Rules]
      summary: Delete a saved rule
      operationId: deleteRule
      responses:
        '204': { description: Deleted. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v2/universes:
    get:
      tags: [Universes]
      summary: List the caller's universes
      description: |
        Paginated list of the caller's own universes, newest-first.
        System universes (`top_10`, `top_100`) are not returned here —
        fetch them via `GET /v2/universes/system`.
      operationId: listUniverses
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Page of universes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UniverseListResponse'
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '503':
          description: A Firestore index needed for this query is still building.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    post:
      tags: [Universes]
      summary: Create a universe
      description: |
        Creates a named ticker list. `id` is optional — when omitted a
        random `u_xxxxxxxx` slug is generated; reserved system slugs
        (`top_10`, `top_100`) are rejected. Every ticker must exist in
        the active scanner universe (typos / delisted symbols return
        `unknown_tickers`). User-defined universes are available on
        Hobby and above; the plan cap is enforced on creation
        (Hobby 5, Pro 25, Scale 100, Enterprise unlimited). A hard cap
        of 10,000 members per universe applies on every tier.
      operationId: createUniverse
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UniverseCreateRequest'
      responses:
        '201':
          description: Universe created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UniverseResponse'
        '400':
          description: |
            Malformed input (`bad_request`) or one or more tickers are
            not tracked / inactive (`unknown_tickers`, with a
            `disallowed` array of rejected symbols).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403':
          description: |
            Plan does not include user-defined universes
            (`universes_tier_required`) or the per-plan cap has been
            reached (`resource_limit_reached`, with `current` and
            `limit` fields).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '409':
          description: A universe with the supplied `id` already exists for this owner (`slug_taken`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  /v2/universes/system:
    get:
      tags: [Universes]
      summary: List system-managed universes
      description: |
        Returns the platform-managed read-only universes (`top_10`,
        `top_100`). These are rebuilt monthly and shared across all
        customers.
      operationId: listSystemUniverses
      responses:
        '200':
          description: System universes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UniverseListResponse'

  /v2/universes/{id}:
    parameters:
      - $ref: '#/components/parameters/UniverseIdPath'
    get:
      tags: [Universes]
      summary: Fetch one universe
      description: Accepts a customer slug (the caller's own universes) or a system slug (`top_10`, `top_100`).
      operationId: getUniverse
      responses:
        '200':
          description: The universe.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UniverseResponse'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      tags: [Universes]
      summary: Update a universe
      description: |
        Partial update of `name`, `description`, or membership.
        Membership precedence: if `tickers` is present it is a full
        replace; otherwise `add` / `remove` are applied as deltas to the
        current membership (uppercased + deduped). Every resulting
        ticker is re-validated against the active scanner universe.
        System universes (`top_10`, `top_100`) are read-only.
      operationId: updateUniverse
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UniversePatchRequest'
      responses:
        '200':
          description: The updated universe.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UniverseResponse'
        '400':
          description: |
            Malformed input (`bad_request`) or one or more tickers are
            not tracked / inactive (`unknown_tickers`, with a
            `disallowed` array of rejected symbols).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403':
          description: Attempted to mutate a system universe (`system_universe_immutable`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      tags: [Universes]
      summary: Delete a universe
      description: System universes (`top_10`, `top_100`) cannot be deleted.
      operationId: deleteUniverse
      responses:
        '204': { description: Deleted. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthenticated' }
        '403':
          description: Attempted to delete a system universe (`system_universe_immutable`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorEnvelope' }
        '404': { $ref: '#/components/responses/NotFound' }

# ─────────────────────────────────────────────────────────────────────────
# Webhooks (OUTGOING — what we POST to your target_url)
# ─────────────────────────────────────────────────────────────────────────
# These describe events Tickerbot sends TO your server. Configure the
# target URL via POST /v2/webhooks. Each delivery carries the headers
# documented at https://tickerbot.io/api/webhook-events; signed with
# HMAC-SHA256 using the subscription's signing_secret.
webhooks:

  webhookPing:
    post:
      tags: [Webhooks]
      summary: Handshake delivery after webhook creation
      description: |
        Sent exactly once after `POST /v2/webhooks`. If your server responds
        2xx within 10 seconds, the subscription transitions from
        `pending_verification` to `active`. Otherwise it follows the
        standard retry schedule and is disabled on permanent failure.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookPingEvent' }
      responses:
        '200':
          description: Acknowledged. The subscription is now active.

  webhookFired:
    post:
      tags: [Webhooks]
      summary: Match-set transition fired
      description: |
        Sent every evaluation cycle in which at least one ticker
        transitioned from not-matching to matching for the subscription's
        WHERE clause. Never an empty payload; never one POST per ticker
        (matches are batched). Tickers already in the match set are NOT
        re-fired — only fresh entries trigger a delivery.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WebhookFiredEvent' }
      responses:
        '200':
          description: Acknowledged. No retry.
        '4XX':
          description: Permanent failure for this delivery. We will not retry.
        '5XX':
          description: |
            Transient failure. Will be retried per the public schedule
            (30s, 2m, 10m, 1h, 6h — five retries, ~8h total) before being
            marked permanent_failure and disabling the subscription.

# ─────────────────────────────────────────────────────────────────────────
# Components
# ─────────────────────────────────────────────────────────────────────────
components:

  securitySchemes:
    apiBearer:
      type: http
      scheme: bearer
      bearerFormat: tb_(test|live)_<key>
      description: |
        Pass your key as `Authorization: Bearer tb_test_...` (test keys)
        or `Authorization: Bearer tb_live_...` (production keys). Keys are
        issued in the dashboard at https://tickerbot.io/dashboard.

  parameters:
    TickerPath:
      name: ticker
      in: path
      required: true
      schema: { type: string, pattern: '^[A-Za-z0-9.\-]{1,12}$', example: AAPL }
      description: Ticker symbol. Case-insensitive.
    TickersList:
      name: tickers
      in: query
      required: false
      schema: { type: string, example: 'NVDA,TSLA,JPM' }
      description: Comma-separated symbol list (up to 50) for bulk full-row lookup. When set, the response returns the full row per symbol and ignores pagination params.
    AsOfRequired:
      name: asof
      in: query
      required: true
      schema: { type: string, example: '2026-03-01' }
      description: 'Target date as YYYY-MM-DD or full ISO timestamp YYYY-MM-DDTHH:MM:SSZ.'
    Cursor:
      name: cursor
      in: query
      required: false
      schema:
        type: string
        maxLength: 1024
        pattern: '^[A-Za-z0-9_-]+$'
      description: |
        Opaque pagination token returned in the previous response's
        `next_cursor`. Pass it back verbatim on the next request to continue
        the walk. The cursor encodes server-side position state — **treat it
        as a black box** and do not decode, modify, or persist its structure
        between deploys. Format: URL-safe base64 (`[A-Za-z0-9_-]+`), max 1024
        bytes. A `null` `next_cursor` in the response means the page you
        just received was the final page.
    Limit:
      name: limit
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
      description: Page size.
    LimitTickers:
      name: limit
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 1000, default: 50 }
      description: Page size for the alphabetical walk.
    LimitSmall:
      name: limit
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    LimitScan:
      name: limit
      in: query
      required: false
      schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
    WebhookIdPath:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^wh_[A-Za-z0-9_-]+$' }
      description: Webhook id returned by POST /v2/webhooks.
    RuleIdPath:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^[a-z][a-z0-9_]{0,62}$' }
      description: Rule slug. Either a customer-supplied slug or the auto-generated `r_xxxxxxxx`.
    UniverseIdPath:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^[a-z][a-z0-9_]{0,62}$' }
      description: |
        Universe slug. Customer-owned universes use either a caller-supplied
        slug or the auto-generated `u_xxxxxxxx`; system universes are
        addressed by bare slug (`top_10`, `top_100`).

  responses:
    BadRequest:
      description: Malformed request — missing parameter, invalid format, value out of range, or unsupported combination.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthenticated:
      description: Missing or invalid Authorization header.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: Resource not found.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: Rate limit exceeded. Retry-After header indicates seconds.
      headers:
        Retry-After: { schema: { type: integer } }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  schemas:

    # ── Shared envelope shapes ─────────────────────────────────────────
    AsOfEnvelope:
      type: object
      required: [as_of]
      properties:
        as_of:
          type: string
          format: date-time
          description: ISO 8601 timestamp indicating data vintage. For live endpoints this is the server time the response was assembled; for as-of-history endpoints it's the requested asof.
          example: '2026-05-14T11:41:01.000Z'

    ErrorEnvelope:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          description: Machine-readable error code.
          enum:
            - bad_request
            - invalid_query
            - unknown_signal
            - signal_not_available_at_interval
            - history_window_exceeded
            - not_found
            - unauthenticated
            - subscription_required
            - webhook_tier_required
            - webhook_limit_reached
            - rules_tier_required
            - universes_tier_required
            - resource_limit_reached
            - slug_taken
            - system_universe_immutable
            - unknown_tickers
            - index_building
            - rate_limited
            - internal
        message:
          type: string
          description: Human-readable error message.
        request_id:
          type: string
          description: Opaque request identifier — include when contacting support.
        # Code-specific extension fields used by some errors.
        max_history_days:
          type: integer
          description: Present on `history_window_exceeded`.
        earliest_allowed_asof:
          type: string
          description: Present on `history_window_exceeded` for asof endpoints.
        earliest_allowed_from:
          type: string
          description: Present on `history_window_exceeded` for from-endpoints.
        current:
          type: integer
          description: Present on `webhook_limit_reached` and `resource_limit_reached` — number of resources currently owned.
        limit:
          type: integer
          description: Present on `webhook_limit_reached` and `resource_limit_reached` — plan cap for the resource.
        disallowed:
          type: array
          items: { type: string }
          description: Present on `unknown_tickers` — the symbols that failed the active-universe check.

    # ── Health & version ────────────────────────────────────────────────
    HealthResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          properties:
            status:  { type: string, example: ok }
            service: { type: string, example: tickerbot-v2 }
            version: { type: string, example: v2.0.0 }

    VersionResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          properties:
            service: { type: string, example: tickerbot-v2 }
            version: { type: string, example: v2.0.0 }
            build:   { type: string, example: abc1234 }

    # ── Tickers ─────────────────────────────────────────────────────────
    TickerListRow:
      type: object
      properties:
        ticker:     { type: string, example: AAPL }
        name:       { type: string, example: 'Apple Inc.' }
        asset_type: { type: string, example: CS }
        exchange:   { type: string, example: XNAS }
        market_cap: { type: integer, nullable: true, example: 4329832548800 }

    TickerFullRow:
      type: object
      description: |
        The full ticker row. Every column from the schema page appears at the top level.
        Boolean flags appear as their unprefixed names (e.g. `golden_cross_today: true`).
        Additional fields are documented on https://tickerbot.io/api/schema.
      properties:
        ticker:         { type: string, example: AAPL }
        name:           { type: string, example: 'Apple Inc.' }
        asset_type:     { type: string, example: CS }
        exchange:       { type: string, example: XNAS }
        country:        { type: string, example: us }
        sector:         { type: string, nullable: true, example: Technology }
        industry:       { type: string, nullable: true, example: 'Consumer Electronics' }
        market_cap:     { type: integer, nullable: true, example: 4329832548800 }
        price:          { type: number, nullable: true, example: 298.74 }
        day_change_pct: { type: number, nullable: true, example: 0.0153 }
        gap_pct:        { type: number, nullable: true, example: 0.0008 }
        volume_today:   { type: integer, nullable: true, example: 42120500 }
        avg_volume_10d: { type: integer, nullable: true, example: 51983000 }
        relative_volume: { type: number, nullable: true, example: 0.81 }
        rsi_14:         { type: number, nullable: true, example: 73.21 }
        pe_ratio:       { type: number, nullable: true, example: 32.4 }
        eps:            { type: number, nullable: true, example: 9.21 }
        high_52w:       { type: number, nullable: true, example: 312.10 }
        low_52w:        { type: number, nullable: true, example: 198.43 }
        above_sma_50:   { type: boolean, example: true }
        above_sma_200:  { type: boolean, example: true }
        golden_cross_today: { type: boolean, example: false }
      additionalProperties: true

    TickersListResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [count, results, next_cursor]
          properties:
            count: { type: integer, example: 3 }
            next_cursor:
              type: string
              nullable: true
              description: Opaque cursor for the next page. `null` on the final page.
            results:
              type: array
              items: { $ref: '#/components/schemas/TickerListRow' }

    TickersBulkResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [requested, count, results]
          properties:
            requested:
              type: array
              items: { type: string }
              example: [NVDA, TSLA, JPM]
            count: { type: integer, example: 3 }
            results:
              type: array
              items:
                oneOf:
                  - $ref: '#/components/schemas/TickerFullRow'
                  - type: object
                    properties:
                      ticker:    { type: string }
                      _not_found: { type: boolean, enum: [true] }

    TickerSingleResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [ticker, data]
          properties:
            ticker: { type: string }
            data:   { $ref: '#/components/schemas/TickerFullRow' }

    TickerHistoryResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [ticker, _meta, data]
          properties:
            ticker: { type: string }
            _meta:
              type: object
              properties:
                resolution: { type: string, example: daily }
                frozen_fields:
                  type: array
                  items: { type: string }
                  description: Fields snapshotted from the current ticker row (not historized).
            data: { $ref: '#/components/schemas/TickerFullRow' }

    # ── Events ──────────────────────────────────────────────────────────
    EventEntry:
      type: object
      properties:
        ts:      { type: string, format: date-time }
        kind:    { type: string, enum: [split, dividend, rating_change] }
        payload: { type: object, description: 'Event-kind-specific shape. See /api/endpoints/tickers for examples.' }

    EventsResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [ticker, count, events, next_cursor]
          properties:
            ticker: { type: string }
            filter:
              type: object
              properties:
                from: { type: string, nullable: true }
                to:   { type: string, nullable: true }
                kind: { type: string, nullable: true }
            count: { type: integer }
            next_cursor: { type: string, nullable: true }
            events:
              type: array
              items: { $ref: '#/components/schemas/EventEntry' }

    # ── Signals ────────────────────────────────────────────────────────
    SignalsMatchRow:
      type: object
      properties:
        ticker: { type: string }
        name:   { type: string }
        value:
          description: 'Numeric value for numeric signals; true/false for boolean flags.'
          oneOf:
            - type: number
            - type: boolean

    SignalsMatchResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [signal, count, results, next_cursor]
          properties:
            signal:     { type: string }
            condition:  { type: string, nullable: true, description: 'Echo of the request condition. Always null for boolean flags.' }
            count:      { type: integer }
            next_cursor: { type: string, nullable: true }
            results:
              type: array
              items: { $ref: '#/components/schemas/SignalsMatchRow' }

    SignalBar:
      type: object
      properties:
        t:
          description: 'YYYY-MM-DD for daily/weekly; full ISO timestamp for hourly/minute.'
          type: string
        v:
          description: 'Numeric value for numeric signals; true/false for boolean flags.'
          oneOf:
            - type: number
            - type: boolean
            - type: 'null'

    SignalHistoryResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [ticker, signal, interval, count, bars, next_cursor]
          properties:
            ticker:     { type: string }
            signal:     { type: string }
            interval:   { type: string, enum: [1m, 1h, 1d, 1w] }
            count:      { type: integer }
            next_cursor: { type: string, nullable: true }
            bars:
              type: array
              items: { $ref: '#/components/schemas/SignalBar' }

    # ── Scan ───────────────────────────────────────────────────────────
    ScanQueryEcho:
      type: object
      properties:
        q:      { type: string }
        limit:  { type: integer }
        order:  { type: string }
        dir:    { type: string, enum: [asc, desc] }
        fields:
          type: array
          items: { type: string }

    ScanResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [query, count, results, next_cursor]
          properties:
            query: { $ref: '#/components/schemas/ScanQueryEcho' }
            count: { type: integer }
            next_cursor: { type: string, nullable: true }
            results:
              type: array
              items: { $ref: '#/components/schemas/TickerFullRow' }

    ScanHistoryResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [query, count, results, next_cursor, _meta]
          properties:
            query:
              allOf:
                - $ref: '#/components/schemas/ScanQueryEcho'
                - type: object
                  properties: { asof: { type: string } }
            _meta:
              type: object
              properties:
                resolution:    { type: string, example: daily }
                frozen_fields: { type: array, items: { type: string } }
                note:          { type: string }
            count: { type: integer }
            next_cursor: { type: string, nullable: true }
            results:
              type: array
              items: { $ref: '#/components/schemas/TickerFullRow' }

    # ── Webhooks ───────────────────────────────────────────────────────
    Webhook:
      type: object
      properties:
        id:                 { type: string, example: wh_smRsF3-z36o }
        userId:             { type: string }
        keyId:              { type: string }
        name:               { type: string, example: 'breakouts-on-tech' }
        q:                  { type: string }
        target_url:         { type: string, format: uri }
        status:             { type: string, enum: [active, pending_verification, disabled] }
        source:             { type: string, example: v2 }
        created_at:         { type: integer, description: 'Unix seconds.' }
        updated_at:         { type: integer, description: 'Unix seconds.' }
        last_evaluated_at:  { type: integer, nullable: true }
        last_match_set:
          type: array
          items: { type: string }
          description: 'Tickers in the match set at the last evaluation (cron uses this for state-change deduplication).'

    WebhookFull:
      allOf:
        - $ref: '#/components/schemas/Webhook'
        - type: object
          required: [signing_secret]
          properties:
            signing_secret:
              type: string
              description: 'HMAC-SHA256 signing key for outbound deliveries. Returned ONLY on POST /v2/webhooks; save it — it is never returned again.'

    WebhookResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - $ref: '#/components/schemas/Webhook'

    WebhookFullResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - $ref: '#/components/schemas/WebhookFull'

    WebhooksListResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [count, webhooks, next_cursor]
          properties:
            count: { type: integer }
            next_cursor: { type: string, nullable: true }
            webhooks:
              type: array
              items: { $ref: '#/components/schemas/Webhook' }

    DeliveryEntry:
      type: object
      properties:
        id:                { type: string, example: dlv_yKt4Ne92PqQXmZ }
        webhook_id:        { type: string }
        event:             { type: string, enum: [webhook.ping, webhook.fired] }
        target_url:        { type: string, format: uri }
        attempt:           { type: integer }
        status:            { type: string, enum: [pending, delivered, permanent_failure] }
        next_attempt_at:   { type: integer }
        last_status_code:  { type: integer, nullable: true }
        last_error:        { type: string, nullable: true }
        created_at:        { type: integer }
        delivered_at:      { type: integer, nullable: true }

    DeliveriesResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [count, deliveries, next_cursor]
          properties:
            count: { type: integer }
            next_cursor: { type: string, nullable: true }
            deliveries:
              type: array
              items: { $ref: '#/components/schemas/DeliveryEntry' }

    # ── Rules ──────────────────────────────────────────────────────────
    Rule:
      type: object
      properties:
        id:               { type: string, example: r_a1b2c3d4, description: 'Per-owner unique slug. Auto-generated as `r_xxxxxxxx` when not supplied on create.' }
        ownerType:        { type: string, enum: [customer], example: customer }
        ownerId:          { type: string, description: 'Firebase uid of the owning account.' }
        createdByKeyId:   { type: string, description: 'Id of the API key used to create the rule.' }
        name:             { type: string, maxLength: 80, example: 'tech-breakouts' }
        description:      { type: string, maxLength: 500, example: 'Technology sector breakouts with above-average volume.' }
        q:                { type: string, maxLength: 4000, example: "breakout AND sector = 'Technology'" }
        universe_id:
          type: string
          nullable: true
          description: |
            Optional universe scope. When null the rule runs against the
            plan's default ticker scope at scan time (top_10 for Free,
            top_100 for Hobby, full universe for Pro+). When set it
            references either a system universe (`top_10`, `top_100`)
            or one owned by the caller.
          example: top_100
        order:            { type: string, pattern: '^[a-z][a-z0-9_]*$', default: 'day_change_pct' }
        dir:              { type: string, enum: [asc, desc], default: desc }
        fields:
          type: array
          items: { type: string, pattern: '^[a-z][a-z0-9_]*$' }
          description: Extra columns to include in each scan result row.
        created_at:       { type: integer, description: 'Unix seconds.' }
        updated_at:       { type: integer, description: 'Unix seconds.' }

    RuleCreateRequest:
      type: object
      required: [name, q]
      properties:
        id:           { type: string, pattern: '^[a-z][a-z0-9_]{0,62}$', description: 'Optional per-owner slug. Auto-generated when omitted.' }
        name:         { type: string, maxLength: 80 }
        description:  { type: string, maxLength: 500 }
        q:            { type: string, maxLength: 4000, description: 'SQL WHERE expression. Semicolons, comments, and write-side keywords rejected.' }
        universe_id:
          type: string
          nullable: true
          description: 'Optional. Must reference a system universe (`top_10`, `top_100`) or a universe the caller owns.'
        order:        { type: string, pattern: '^[a-z][a-z0-9_]*$', default: 'day_change_pct' }
        dir:          { type: string, enum: [asc, desc], default: desc }
        fields:
          type: array
          items: { type: string, pattern: '^[a-z][a-z0-9_]*$' }

    RulePatchRequest:
      type: object
      description: Partial update — send only the fields you want to change.
      properties:
        name:         { type: string, maxLength: 80 }
        description:  { type: string, maxLength: 500 }
        q:            { type: string, maxLength: 4000 }
        universe_id:
          type: string
          nullable: true
          description: 'Send `null` or `""` to detach the rule from its universe.'
        order:        { type: string, pattern: '^[a-z][a-z0-9_]*$' }
        dir:          { type: string, enum: [asc, desc] }
        fields:
          type: array
          items: { type: string, pattern: '^[a-z][a-z0-9_]*$' }

    RuleResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - $ref: '#/components/schemas/Rule'

    RuleListResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [count, rules, next_cursor]
          properties:
            count: { type: integer }
            next_cursor: { type: string, nullable: true }
            rules:
              type: array
              items: { $ref: '#/components/schemas/Rule' }

    # ── Universes ──────────────────────────────────────────────────────
    Universe:
      type: object
      properties:
        id:               { type: string, example: u_a1b2c3d4, description: 'Per-owner unique slug. Auto-generated as `u_xxxxxxxx` when not supplied on create. System universes use bare slugs (`top_10`, `top_100`).' }
        ownerType:        { type: string, enum: [customer, system], example: customer }
        ownerId:          { type: string, nullable: true, description: 'Firebase uid of the owning account. Absent / null on system universes.' }
        createdByKeyId:   { type: string, nullable: true, description: 'Id of the API key used to create the universe. Null on system universes.' }
        name:             { type: string, maxLength: 80, example: 'core-watchlist' }
        description:      { type: string, maxLength: 500 }
        tickers:
          type: array
          items: { type: string, pattern: '^[A-Z0-9.\-]{1,12}$' }
          example: [NVDA, TSLA, AAPL]
        size:             { type: integer, description: 'Member count. Mirrors `tickers.length`.' }
        system:           { type: boolean, description: 'True for the platform-managed `top_10` / `top_100` universes; false for customer-owned.' }
        created_at:       { type: integer, description: 'Unix seconds.' }
        updated_at:       { type: integer, description: 'Unix seconds.' }

    UniverseCreateRequest:
      type: object
      required: [name, tickers]
      properties:
        id:           { type: string, pattern: '^[a-z][a-z0-9_]{0,62}$', description: 'Optional per-owner slug. Reserved system slugs (`top_10`, `top_100`) are rejected.' }
        name:         { type: string, maxLength: 80 }
        description:  { type: string, maxLength: 500 }
        tickers:
          type: array
          minItems: 1
          maxItems: 10000
          items: { type: string, pattern: '^[A-Za-z0-9.\-]{1,12}$' }
          description: 'Symbols are upper-cased and de-duped server-side. Every entry must exist in the active scanner universe.'

    UniversePatchRequest:
      type: object
      description: |
        Partial update. Membership precedence: `tickers` (full replace)
        wins over `add` / `remove` (deltas applied to current
        membership). Sending neither leaves the membership unchanged.
      properties:
        name:         { type: string, maxLength: 80 }
        description:  { type: string, maxLength: 500 }
        tickers:
          type: array
          maxItems: 10000
          items: { type: string, pattern: '^[A-Za-z0-9.\-]{1,12}$' }
          description: 'Full replace. Mutually exclusive in practice with `add`/`remove` (takes precedence when both are sent).'
        add:
          type: array
          items: { type: string, pattern: '^[A-Za-z0-9.\-]{1,12}$' }
          description: 'Symbols to add to the current membership.'
        remove:
          type: array
          items: { type: string, pattern: '^[A-Za-z0-9.\-]{1,12}$' }
          description: 'Symbols to remove from the current membership.'

    UniverseResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - $ref: '#/components/schemas/Universe'

    UniverseListResponse:
      allOf:
        - $ref: '#/components/schemas/AsOfEnvelope'
        - type: object
          required: [count, universes, next_cursor]
          properties:
            count: { type: integer }
            next_cursor: { type: string, nullable: true }
            universes:
              type: array
              items: { $ref: '#/components/schemas/Universe' }

    # ── Outgoing webhook payloads ─────────────────────────────────────────
    # Bodies of the POST requests we send to your target_url. Referenced
    # from the top-level `webhooks:` section. Field shapes mirror
    # tickerbot-backend/src/webhookScheduler.ts (buildPingPayload,
    # buildRuleFiredPayload).

    WebhookPingEvent:
      type: object
      required: [event, webhook_id, name, as_of, message]
      properties:
        event:
          type: string
          enum: [webhook.ping]
        webhook_id:
          type: string
          example: wh_8f3a2b
        name:
          type: string
          description: Human-readable webhook name set at creation.
          example: small-cap gappers
        as_of:
          type: string
          format: date-time
          description: ISO 8601 timestamp of when the webhook was created.
          example: '2026-04-30T14:40:00Z'
        message:
          type: string
          example: 'If you can read this, your endpoint is reachable.'

    WebhookFiredEvent:
      type: object
      required: [event, webhook_id, name, q, as_of, matches]
      properties:
        event:
          type: string
          enum: [webhook.fired]
        webhook_id:
          type: string
          example: wh_8f3a2b
        name:
          type: string
          example: small-cap gappers
        q:
          type: string
          description: |
            Snapshot of the WHERE clause that matched. May lag the
            subscription's current `q` if the rule was edited between
            this delivery's queue time and its first attempt; retries
            sign and POST the exact same bytes.
          example: 'gap_up_3pct AND volume_unusual_2x AND market_cap < 2000000000 AND NOT earnings_this_week'
        as_of:
          type: string
          format: date-time
          description: |
            Evaluation-cycle timestamp. Identical for every ticker in this
            payload. Use this (not your receive time) to align fires with
            external data.
          example: '2026-04-30T15:05:00Z'
        matches:
          type: array
          description: |
            Tickers that transitioned from not-matching to matching this
            cycle. Each entry has the ticker symbol, name, and the
            columns referenced in the WHERE clause snapshotted at the
            moment of match. Additional default columns (price,
            day_change_pct) are always included.
          items:
            type: object
            required: [ticker, name]
            properties:
              ticker: { type: string, example: RIOT }
              name:   { type: string, example: 'Riot Platforms' }
            additionalProperties: true
