Owlviz · API docs

Reliable asset data for Solana.

Base URL: https://solana-metadata-platform-production.up.railway.app

Try example mint or start with quick start.

Collections

Collection endpoints expose indexed collection summaries, paginated asset lists, aggregated health, and background indexing status.

Base URL: https://solana-metadata-platform-production.up.railway.app

All collection routes are **public**. Collection address parameters must be valid Solana public keys.

Endpoints

MethodPathRate tierAuthDescription
GET/v1/collections/:addressexpensivenoneCollection entity + embedded health
GET/v1/collections/:address/assetsexpensivenonePaginated indexed assets
GET/v1/collections/:address/healthexpensivenoneAggregated health summary
GET/v1/collections/:address/marketexpensivenoneMulti-provider floor snapshot (Magic Eden + Tensor)
POST/v1/collections/:address/indexexpensivenoneQueue background indexing
GET/v1/collections/:address/indexlightweightnoneLatest index job status
GET/v1/collection-index-jobs/:jobIdlightweightnoneJob status by ID

Collection summary

GET /v1/collections/:address returns:

json
{
  "data": {
    "address": "...",
    "name": "...",
    "image": "...",
    "metadataUri": "...",
    "verified": true,
    "supply": 10000,
    "indexedAssetCount": 842,
    "indexing": {
      "status": "complete",
      "discoveredCount": 842,
      "processedCount": 842,
      "failedCount": 0,
      "startedAt": "...",
      "completedAt": "...",
      "updatedAt": "..."
    },
    "health": { /* CollectionHealthSummary or null */ },
    "lastIndexedAt": "..."
  }
}

Index status values

not_started, queued, running, partial, complete, failed

  • **complete** — indexing finished for discovered assets
  • **partial** — interrupted or incomplete; health reflects indexed assets only
  • **not_started** — collection registered but never indexed

Paginated assets

GET /v1/collections/:address/assets

QueryValuesDescription
limit1–100 (default 50)Page size
cursoropaque stringPagination cursor from prior response
healthStatushealthy, warning, degraded, criticalFilter by band
sorthealth_asc, health_desc, nameSort order
needsAttentiontrue / falseAssets with degraded/critical health

Response:

json
{
  "data": [
    {
      "mint": "...",
      "name": "...",
      "image": "...",
      "assetType": "nft",
      "health": { "score": 74, "status": "warning" }
    }
  ],
  "pagination": { "nextCursor": "...", "limit": 50 }
}

Returns **404** COLLECTION_NOT_INDEXED when indexStatus === 'not_started'.

Collection health

GET /v1/collections/:address/health returns aggregated metrics:

json
{
  "data": {
    "address": "...",
    "score": 72,
    "status": "warning",
    "coverage": { "discovered": 842, "indexed": 842, "percent": 100 },
    "totals": { "assets": 842, "healthy": 400, "warning": 300, "degraded": 100, "critical": 42 },
    "metadata": { "missingImages": 12, "missingMetadataUris": 3, "offchainFailures": 8, "missingDescriptions": 20, "invalidAttributes": 1 },
    "collection": { "verified": 800, "unverified": 30, "unknown": 12 },
    "authorities": { "mintAuthorityActive": 0, "freezeAuthorityActive": 2, "permanentDelegateActive": 0, "transferHookActive": 1 },
    "standards": { "metaplex": 800, "programmable_nft": 40, "compressed_nft": 2, "unknown": 0 },
    "tokenPrograms": { "spl_token": 820, "token_2022": 22, "unknown": 0 },
    "primaryStandard": "metaplex",
    "primaryTokenProgram": "spl_token",
    "calculatedAt": "..."
  }
}

Standards vs token programs (keep separate)

Two orthogonal distributions:

FieldBucketsSource
standardsmetaplex, programmable_nft, compressed_nft, unknownMetadata standard from NormalizedAsset.assetType + standard
tokenProgramsspl_token, token_2022, unknownOn-chain program from NormalizedAsset.tokenProgram.type

Do not conflate metadata standard counts with token program counts.

Indexing

Start or resume

POST /v1/collections/:address/index?refresh=false

  • **202** — new job queued
  • **200** — active job already exists (returns existing job)
  • **409** INDEX_ALREADY_RUNNING — active job exists and refresh=true was requested

Status

GET /v1/collections/:address/index or GET /v1/collection-index-jobs/:jobId

json
{
  "data": {
    "jobId": "...",
    "collection": "...",
    "status": "running",
    "discovered": 500,
    "processed": 320,
    "failed": 2,
    "page": 4,
    "cursor": "...",
    "progressPercent": 64.4,
    "lastError": null,
    "startedAt": "...",
    "updatedAt": "...",
    "completedAt": null
  }
}

Indexing runs in a **bounded background worker**. Large collections may remain partial until the worker catches up.

Collection monitors and freshness

When a collection monitor is active and the index is older than COLLECTION_MONITOR_FRESHNESS_SECONDS (default **3600**), the worker re-enqueues indexing. Monitors may enter waitingForIndex until coverage is sufficient. See [monitoring.md](./monitoring.md).

Cache headers

Collection summary and health responses include X-Cache and X-Cache-TTL when Redis caching is enabled.

Collection market (Sprint 2)

GET /v1/collections/:address/market queries enabled marketplace providers concurrently for a **canonical Solana collection address** Owlviz already understands. Callers pass the same collection address used for summary/health routes — not Magic Eden symbols or Tensor slugs.

Owlviz maps the collection address to each provider internally using an indexed or Helius-discovered sample mint. Provider failures are isolated: one marketplace outage does not block the other. The response exposes each provider independently in sources[] and the **best current observed floor** in aggregate.bestFloor with full provenance.

Server configuration

VariableDescription
MAGIC_EDEN_API_BASE_URLMagic Eden REST base (default https://api-mainnet.magiceden.dev/v2)
MAGIC_EDEN_API_KEYOptional Magic Eden API key
TENSOR_API_BASE_URLTensor Alpha REST base (default https://api.mainnet.tensordev.io)
TENSOR_API_KEYTensor API key (x-tensor-api-key header). When omitted, Tensor is auto-disabled even if listed in MARKET_ENABLED_PROVIDERS.
TENSOR_IDENTITY_CACHE_TTL_SECONDSTensor verified-mint identity mapping cache TTL (default **86400** / 24h). Invalidated only on definitive unmapped resolution, not on 429/timeouts/5xx.
MARKET_ENABLED_PROVIDERSComma-separated provider ids (default magic_eden,tensor)

Never expose provider API keys via NEXT_PUBLIC_* or client bundles.

Query parameters

QueryValuesDescription
scopeprovider_only (default), provider, magic_eden, tensor, aggregated, all_marketsProvider listing mode. Magic Eden aggregated uses ME listing aggregation; Tensor always reads active listings sorted by PriceAsc (limit=1).

Response statuses

Top-level status / floor / provider remain the **Magic Eden** observation for backward compatibility with Sprint 1 clients. Multi-provider consumers should read aggregate.bestFloor and sources[].

StatusMeaning
successSOL floor price available (floor.sol + floor.lamports)
no_listingsCollection mapped on the provider but no active SOL floor listings
unmappedProvider could not map this collection address (Tensor: verified mint + sample mint both failed)
unavailableProvider upstream error; stale cached floor may be returned per provider with freshness: stale

Tensor collection identity (Sprint 3.1)

Tensor resolves collection identity in strict order — **never** fuzzy-matching collection names or slugs:

1. **Verified on-chain mint** — GET /api/v1/collections?vocs={collectionAddress}&sortBy=slugDisplay:asc&limit=1 (official Tensor API).

2. **Sample mint fallback** — indexed or Helius-discovered asset mint via GET /api/v1/collections/by-nfts?mints=….

Positive and negative identity mappings cache independently in Redis (tensor-identity:v1:{address}, default TTL **24h**). Transient Tensor errors (429, timeouts, 5xx) do **not** overwrite cached mappings. Floor reads use SOL listings only (currencies=So111… + response filtering).

Provider failures remain isolated: Magic Eden and Tensor resolve independently in sources[].

Example (multi-provider):

json
{
  "data": {
    "address": "AxFuniPo7RaDgPH6Gizf4GZmLQFc4M5ipckeeZfkrPNn",
    "status": "success",
    "floor": { "sol": 3.89, "lamports": "3890000000" },
    "listingCount": 482,
    "observedAt": "2026-09-08T16:00:00.000Z",
    "scope": "provider_only",
    "provider": {
      "id": "magic_eden",
      "symbol": "degods",
      "sampleMint": "Cuyvxd6G3fmKE6qNFy7UdjHYCxfoMcuCizRvJxX1pC9y",
      "attribution": {
        "id": "magic_eden",
        "name": "Magic Eden",
        "url": "https://magiceden.io"
      }
    },
    "aggregate": {
      "bestFloor": {
        "floor": { "sol": 3.75, "lamports": "3750000000" },
        "status": "success"
      }
    },
    "sources": [
      {
        "status": "success",
        "floor": { "sol": 3.89, "lamports": "3890000000" },
        "listingCount": 482,
        "observedAt": "2026-09-08T16:00:00.000Z",
        "provider": { "id": "magic_eden", "symbol": "degods", "sampleMint": "...", "attribution": { "id": "magic_eden", "name": "Magic Eden", "url": "https://magiceden.io" } },
        "freshness": "fresh"
      },
      {
        "status": "success",
        "floor": { "sol": 3.75, "lamports": "3750000000" },
        "listingCount": 410,
        "observedAt": "2026-09-08T16:00:01.000Z",
        "provider": { "id": "tensor", "symbol": "degods", "sampleMint": "...", "attribution": { "id": "tensor", "name": "Tensor", "url": "https://tensor.trade" } },
        "freshness": "fresh"
      }
    ],
    "provenance": {
      "bestFloorProviderId": "tensor",
      "method": "lowest_fresh_lamports",
      "comparedProviders": ["magic_eden", "tensor"]
    },
    "freshness": {
      "allFresh": true,
      "staleProviders": [],
      "observedAt": "2026-09-08T16:00:01.000Z"
    },
    "comparison": {
      "spreadLamports": "140000000",
      "spreadSol": 0.14,
      "providerCountWithFreshFloors": 2
    },
    "cache": {
      "fresh": true,
      "cachedAt": "2026-09-08T16:00:00.000Z",
      "ttlSeconds": 120,
      "staleMaxAgeSeconds": 900
    }
  }
}

Best floor policy

  • Compare floors in lamports across providers.
  • Only **fresh** observations compete for aggregate.bestFloor. Stale snapshots may surface per-provider in sources[] but never beat a fresh floor.
  • Listing counts are **never summed** across providers.

Cache headers

Market responses include X-Cache (HIT, MISS, or STALE), X-Cache-TTL, and X-Cache-Age when Redis is configured. Each provider maintains an independent Redis key (collection-market:v1:{provider}:{scope}:{address}). Fresh TTL defaults to **120s**; stale snapshots may be served for up to **900s** when a provider is unavailable.

Sprint 2 scope boundary

**In scope:** Magic Eden + Tensor concurrent floor reads, per-provider cache/stale fallback, best observed floor + provenance/spread, dashboard Simple/Dev display.

**Out of scope (explicit STOP):** Owltopia dynamic raffle ticket pricing, raffle formulas, USD conversion, rarity/trait pricing, WebSockets, trading, separate marketplace indexer, Sprint 4 Market Watch.

See docs/api/examples/10-collection-market.ts for a runnable example. Smoke validation uses the curated list in @metadata-platform/market-intelligence (CURATED_SOLANA_COLLECTIONS, ≥10 entries); CI mocks provider HTTP. Live Tensor validation requires TENSOR_API_KEY in the deployment environment.

Collection market reference (Sprint 3)

GET /v1/collections/:address/market/reference?window=15m returns **Spot Floor** and **Reference Floor** for a canonical collection address. Owlviz reports market facts only — no fair value, intrinsic value, or buy/sell guidance.

  • **Spot Floor** — aggregate.bestFloor from the concurrent market snapshot (lowest fresh provider lamports).
  • **Reference Floor** — integer-lamport rolling median over time-bucketed provider observations stored in Postgres (market_observations).

Server configuration

VariableDescription
MARKET_HISTORY_RETENTION_DAYSObservation retention (default **7**)
MARKET_REFERENCE_MIN_BUCKETSMinimum distinct temporal buckets before status becomes ready (default **5**)
MARKET_OBSERVATION_BUCKET_SECONDSProvider bucket width (default **60**)
MARKET_REFERENCE_MAX_GAP_MULTIPLIERSparse gap threshold multiplier vs expected cadence (default **3**)

Observations persist only at upstream refresh boundaries (after real provider fetches). Redis cache hits do not insert duplicates. Persistence failures are non-fatal — /market continues to work.

Query parameters

QueryValuesDescription
window5m, 15m (default), 1hRolling observation window

Reference algorithm

1. Only success observations with a qualifying SOL floor and freshness: fresh enter reference math. unavailable / no_listings are never treated as zero.

2. Observations are bucketed per provider at ~60s boundaries; the latest valid fresh floor per bucket is kept so chatty providers do not dominate.

3. Reference floor = **rolling median** of bucket lamport values (integer-safe). Even bucket counts use the integer average of the two central values: (a + b) / 2 with BigInt division.

4. Status: ready (enough **distinct temporal buckets** + fresh data), warming_up (insufficient distinct buckets), stale (enough buckets but newest observation exceeds stale threshold), unavailable (no qualifying observations).

5. bucketCount counts provider×time buckets used in the median; distinctTimeBucketCount counts unique temporal buckets across providers.

6. Coverage semantics (additive, Sprint 5): coverage.status (sufficient | sparse | insufficient | stale), coverage.mode (single_provider | multi_provider | none), temporal span/gap stats, and per-provider contribution counts separate from current provider snapshot status.

Example:

json
{
  "data": {
    "address": "AxFuniPo7RaDgPH6Gizf4GZmLQFc4M5ipckeeZfkrPNn",
    "status": "ready",
    "spot": {
      "floor": { "sol": 3.75, "lamports": "3750000000" },
      "status": "success"
    },
    "reference": {
      "floor": { "sol": 3.82, "lamports": "3820000000" },
      "method": "rolling_median_lamports",
      "window": "15m",
      "sampleCount": 24,
      "bucketCount": 8,
      "distinctTimeBucketCount": 6,
      "providerCount": 2,
      "computedAt": "2026-09-08T16:00:00.000Z"
    },
    "coverage": {
      "status": "sufficient",
      "mode": "multi_provider",
      "distinctTimeBucketCount": 6,
      "maxGapSeconds": 120,
      "expectedMaxGapSeconds": 360,
      "historySpanSeconds": 480,
      "oldestObservedAt": "2026-09-08T15:52:00.000Z",
      "newestObservedAt": "2026-09-08T16:00:00.000Z"
    },
    "movement": {
      "spotVsReferenceLamports": "-70000000",
      "spotVsReferenceBps": -183
    },
    "range": {
      "low": { "sol": 3.70, "lamports": "3700000000" },
      "high": { "sol": 3.95, "lamports": "3950000000" },
      "spreadBps": 675
    },
    "providers": {
      "observed": ["magic_eden", "tensor"],
      "contributions": { "magic_eden": 4, "tensor": 4 },
      "current": { "magic_eden": "success", "tensor": "success" }
    }
  }
}

See docs/api/examples/11-collection-market-reference.ts for a runnable example.

Sprint 3 scope boundary

**In scope:** market_observations table, observation persistence at refresh boundary, reference endpoint, dashboard Spot/Reference display, retention cleanup via worker.

**Out of scope (explicit STOP):** Owltopia pricing / raffle ticket prices, fair-value language, separate marketplace indexer, Sprint 4+ work.

Market Watch — continuous sampling (Sprint 4)

Opt-in **market watches** schedule genuine Magic Eden + Tensor upstream refreshes from the **existing Railway worker** (no separate service). Each tick bypasses fresh Redis market cache, updates provider caches after real fetches, and persists market_observations only at upstream refresh boundaries (Sprint 3 rules still apply — cache HIT / stale fallback never creates history).

**Provider API usage:** continuous sampling increases Magic Eden and Tensor API call volume proportionally to (active watches / interval). Default interval is **120s** with a hard cap of **20** active watches.

Server configuration

VariableDefaultDescription
MARKET_WATCH_ENABLEDfalseWorker polls market_watches when true
MARKET_WATCH_DEFAULT_INTERVAL_SECONDS120Default watch interval
MARKET_WATCH_MIN_INTERVAL_SECONDS60Minimum allowed interval
MARKET_WATCH_MAX_ACTIVE20Bounded active watch cap
MARKET_WATCH_WORKER_CONCURRENCY2Parallel watch ticks per worker
MARKET_WATCH_POLL_INTERVAL_MS5000Worker claim loop interval
MARKET_WATCH_MAX_BACKOFF_SECONDS900Max backoff after total provider failure
MARKET_WATCH_DEFAULT_JITTER_SECONDS15Random jitter added to successful tick scheduling
PUBLIC_MARKET_WATCH_WRITESfalsePrivate-beta gate for watch CRUD
MONITORING_ADMIN_TOKENBearer token for watch writes (never expose via NEXT_PUBLIC_*)

Market Watch ticks reuse the same provider configuration as GET /market: set MARKET_ENABLED_PROVIDERS (default magic_eden,tensor) and TENSOR_API_KEY on the **worker** service. When TENSOR_API_KEY is present, Tensor is always queried on worker ticks even if MARKET_ENABLED_PROVIDERS lists only magic_eden (a common split when the API and worker env differ). Without TENSOR_API_KEY, Tensor is omitted from ticks and lastTick.providerStatuses will not include tensor.

Watch endpoints

MethodPathAuth
POST/v1/market-watchesBearer write
GET/v1/market-watchespublic
GET/v1/market-watches/:idpublic
GET/v1/collections/:address/market-watchpublic
PATCH/v1/market-watches/:idBearer write (pause/resume/interval/refreshNow)
DELETE/v1/market-watches/:idBearer write
POST/v1/market-watches/:id/tickBearer write (manual immediate tick)

GET /v1/collections/:address/market and GET /v1/collections/:address/market/reference remain unchanged for public clients.

Validation collections (manual smoke)

CollectionAddressNotes
Mad LadsJ1S9H3QjnRtBbbuD4HjPV6RpRhwuk4zKbxsnCHuTgh9wExpect ME success, Tensor no_listings is valid (not unmapped)
OwltopiaIndex first; ME symbol owltopiaSample mint 3PEKevzcSuxnTGVujkvVbFrm8kg858Y6KSVW1Wo8NXuC
Famous Fox (classic Metaplex)A7p8451ktDCHq5yYaHczMUMtAeF5EoS3MUJS9tYoXsEToken-metadata collection

Sprint 4 scope boundary

**In scope:** market_watches table + migration, worker claim loop, genuine observation accumulation, admin CRUD, docs/tests.

**Out of scope (explicit STOP):** Owltopia dynamic raffle ticket pricing, fair/recommended pricing, floor-manipulation policy, trust scores, alerts, market-change webhooks, Tensor/ME WebSockets, third provider, global all-collection polling/discovery, 24h volume, sales/bid history, trait pricing, USD conversion, trading, scraping, billing, public self-service subscriptions, **Sprint 6+ work**.

Reference integrity & coverage (Sprint 5)

Sprint 5 hardens GET /v1/collections/:address/market/reference so clients can distinguish single-provider vs multi-provider history, sparse temporal gaps, and readiness — without changing Spot/Reference floor math or Owltopia pricing.

Coverage fields

FieldDescription
reference.distinctTimeBucketCountUnique temporal buckets across providers (readiness threshold uses this, not raw bucketCount)
coverage.statussufficient · sparse · insufficient · stale
coverage.modesingle_provider · multi_provider · none (based on providers contributing numeric floors to reference math)
coverage.maxGapSecondsLargest gap between consecutive distinct temporal buckets
coverage.expectedMaxGapSecondsActive watch intervalSeconds × MARKET_REFERENCE_MAX_GAP_MULTIPLIER (default cadence **120s** when no active watch)
coverage.historySpanSecondsSeconds between oldest and newest qualifying observations
providers.contributionsPer-provider bucket counts used in reference math
providers.currentCurrent snapshot status per provider (no_listings ≠ unavailable)

Validation notes

CollectionAddressExpected
Mad LadsJ1S9H3QjnRtBbbuD4HjPV6RpRhwuk4zKbxsnCHuTgh9wME success, Tensor no_listings; reference may be ready with coverage.mode: single_provider
Dual-provider floorAny collection with fresh ME + Tensor floorscoverage.mode: multi_provider when both contribute numeric buckets
Famous FoxA7p8451ktDCHq5yYaHczMUMtAeF5EoS3MUJS9tYoXsERetain unmapped / no_listings semantics; do not force a floor

Sprint 5 scope boundary

**In scope:** additive reference integrity/coverage fields, deterministic tests, Dev/Simple dashboard display, docs/OpenAPI.

**Out of scope (explicit STOP):** Owltopia pricing, ticket formulas, fair/safe/trusted floors, confidence/trust scores, recommendations, manipulation detection, market events/webhooks/alerts, WebSockets, new providers, sales/volume/bids/traits/rarity, USD, trading, scraping, broad polling, billing.

---

Sprint 6 — Market Change Events (internal durable events)

Sprint 6 adds **durable, queryable market change events** emitted by the Market Watch observation pipeline. Events are persisted internally first; **webhook delivery is deferred to Sprint 6.1**.

Event types

TypeWhen emitted
spot_floor_changedAggregate spot (best observed) floor moves beyond bps + lamport thresholds for MARKET_EVENT_HYSTERESIS_TICKS consecutive ticks
reference_floor_changedRolling reference floor moves beyond reference thresholds with hysteresis
provider_floor_changedPer-provider floor (ME/Tensor) moves beyond provider thresholds with hysteresis
provider_status_changedProvider status transition (e.g. unavailablesuccess) sustained across hysteresis ticks

Detection semantics (Sprint 6H)

  • **Thresholds:** configurable minimum bps **and** lamports per event class (MARKET_EVENT_*_MIN_BPS, MARKET_EVENT_*_MIN_LAMPORTS) — both must be met.
  • **Moving-target hysteresis:** floor moves track an **anchor** (lane baseline) plus **direction** and consecutiveQualifyingTicks. Sustained moves with shifting targets (e.g. 7.20→7.00→6.95) accumulate toward emit without requiring identical target lamports each tick. Re-entry inside threshold or direction reversal resets the pending candidate.
  • **Independent lane baselines:** state version **2** stores per-lane baselines (spot, reference, per-provider floor/status). Emitting one lane does not advance other lanes' baselines.
  • **Reference stability:** reference_floor_changed emits only when reference status is **ready**.
  • **Provider semantics:** no_listings/unmapped → floor appearance is tracked; provider status detection runs on normalized unavailable responses even when the watch tick fails (all providers unavailable). Fetch **exceptions** do not advance status hysteresis.
  • **Occurrence-safe fingerprints:** SHA-256 fingerprint includes candidateStartedAt so repeated identical transitions later produce distinct events; crash/retry of the same occurrence remains idempotent (created=false).
  • **Baseline:** first tick establishes market_watches.last_market_state_json (v2) without emitting events. Legacy v1 state is converted on read with zero synthetic events.

Storage

Table: market_events

ColumnDescription
collection_addressCollection the event belongs to
watch_idOptional FK to the emitting market watch
event_typeOne of the types above
payload_jsonSnapshot facts: spot/reference/provider deltas, spotVsReferenceBps
fingerprintDedupe key
occurred_atTick timestamp

Read API (public)

MethodPathDescription
GET/v1/market-eventsList events (collectionAddress, watchId, eventType, since, cursor pagination)
GET/v1/market-events/{id}Get one event
GET/v1/collections/{address}/market/eventsCollection-scoped list (requires indexed collection)

Configuration

VariableDefaultPurpose
MARKET_EVENTS_ENABLEDtrueMaster gate for detection + persistence
MARKET_EVENT_SPOT_FLOOR_MIN_BPS50Spot floor change threshold (bps)
MARKET_EVENT_SPOT_FLOOR_MIN_LAMPORTS10000000Spot floor change threshold (lamports)
MARKET_EVENT_REFERENCE_FLOOR_MIN_BPS50Reference floor threshold (bps)
MARKET_EVENT_REFERENCE_FLOOR_MIN_LAMPORTS10000000Reference floor threshold (lamports)
MARKET_EVENT_PROVIDER_FLOOR_MIN_BPS50Provider floor threshold (bps)
MARKET_EVENT_PROVIDER_FLOOR_MIN_LAMPORTS10000000Provider floor threshold (lamports)
MARKET_EVENT_HYSTERESIS_TICKS2Consecutive qualifying ticks before emit
MARKET_EVENT_RETENTION_DAYS30Worker deletes market_events older than this (hourly job; failures logged, non-blocking)

Dashboard

Dev mode on the collection scanner market card shows **Recent market events** (latest 5) when available. Simple mode unchanged.

Sprint 6H scope boundary

**In scope:** production hardening of Sprint 6 event semantics (moving hysteresis, independent baselines, occurrence fingerprints, failure-path status detection, pagination, retention cleanup, legacy state migration), tests, docs.

**STOP — Sprint 6.1 deferred:** webhook delivery, external reactions, Discord/Slack pings, market-change webhook subscriptions, new event types, pricing, analytics, new providers. Ship 6H only; soak before 6.1 webhooks.

**Out of scope (explicit STOP):** Owltopia pricing, fair/safe/trusted language, trading, scraping, new marketplaces, WebSockets, billing, Sprint 7.