OwlViz · API docs

Asset & collection intelligence for Solana.

Base URL: https://api.owlviz.dev

Try example mint , agents, or quick start.

Collections

**OwlViz** collection endpoints expose indexed collection summaries, paginated asset lists, aggregated health, multi-provider market floors, reference floors, durable market change events, and background indexing status.

Base URL: https://api.owlviz.dev

Collection **reads** are public. **One-shot auto-index on lookup** (when a collection is not registered yet) runs only for authenticated developer API keys (Authorization: Bearer ov_live_…) or monitoring admin tokens — never for anonymous clients. Signed-in OwlViz dashboard users trigger the same registration path via session-backed /api/developer/collections/:address BFF routes (API keys never enter the browser).

Collection address parameters must be valid Solana public keys.

Endpoints

MethodPathRate tierAuthDescription
GET/v1/collections/:addressexpensivenone (auto-index when keyed)Collection entity + embedded health
GET/v1/collections/:address/assetsexpensivenonePaginated indexed assets
GET/v1/collections/:address/healthexpensivenoneAggregated health summary
GET/v1/collections/:address/holdersexpensivenone (refresh keyed/admin)Holder distribution (ownership facts)
GET/v1/collections/:address/marketexpensivenone (auto-index when keyed)Multi-provider floor snapshot (Magic Eden + Tensor)
GET/v1/collections/:address/market/referenceexpensivenoneSpot + rolling reference floor
GET/v1/collections/:address/market/eventsexpensivenoneDurable market change events for collection
GET/v1/collections/:address/market/historyexpensivenoneObservation bucket time series from market_observations
GET/v1/collections/:address/market/stateexpensivenoneCombined spot, reference, 1h/24h change, coverage ([market-state.md](./market-state.md))
GET/v1/collections/:address/market-watchlightweightnoneOpt-in continuous sampling watch status
POST/v1/collections/:address/indexexpensive**API key or admin**Queue 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,
      "indexedCount": 842,
      "expectedCount": 10000,
      "coverage": "complete",
      "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": "..."
  }
}

Collection holder distribution

GET /v1/collections/:address/holders returns **ownership distribution facts** for indexed assets (separate from metadata health). Responses are served from Redis/Postgres summaries computed by the background worker — anonymous reads never trigger a full ownership crawl.

json
{
  "data": {
    "address": "...",
    "uniqueHolders": 842,
    "indexedSupply": 842,
    "coveragePercent": 100,
    "avgPerHolder": 1.02,
    "medianPerHolder": 1,
    "concentration": {
      "top1": { "holders": 1, "nfts": 120, "percentOfSupply": 14.3 },
      "top10": { "holders": 10, "nfts": 410, "percentOfSupply": 48.7 },
      "top50": { "holders": 50, "nfts": 720, "percentOfSupply": 85.5 },
      "top100": { "holders": 100, "nfts": 800, "percentOfSupply": 95.0 }
    },
    "bands": {
      "singles": { "holders": 600, "nfts": 600, "percentOfSupply": 71.3 },
      "small": { "holders": 180, "nfts": 520, "percentOfSupply": 61.8 },
      "medium": { "holders": 50, "nfts": 500, "percentOfSupply": 59.4 },
      "whales": { "holders": 12, "nfts": 400, "percentOfSupply": 47.5 }
    },
    "listedCount": 42,
    "listedPercent": 5.0,
    "exclusions": {
      "omitted": [{ "address": "...", "reason": "verified_creator" }],
      "omittedNfts": 3
    },
    "calculatedAt": "...",
    "indexCompletedAt": "...",
    "source": "indexed_asset_snapshots",
    "status": "ready"
  }
}
FieldMeaning
indexedSupplyIndexed mints with a resolved owner in snapshots
coveragePercentResolved owners / expected collection supply when known, else discovered indexed mints
expectedCountKnown collection supply (grand_total) when available
indexIncompletePresent when indexed mint count is below expectedCount
concentration.top*Share of indexed supply held by top N wallets (after exclusions)
bands.*Holder wallet counts by NFTs held: singles (1), small (2–5), medium (6–20), whales (21+)
exclusionsVerified creators, update authorities, and known escrow wallets omitted from concentration
calculatedAtWhen holder stats were last computed from indexed snapshots
indexCompletedAtWhen the collection index last completed (or last batch timestamp while indexing)
statusready, partial (incomplete index), stale (TTL expired, index newer than stats, active/failed recompute), unavailable (no summary yet)

Optional listedCount / listedPercent come from cached Magic Eden + Tensor listing counts when available (no extra marketplace indexer).

Recompute cadence

TriggerAuth
Index job completesautomatic (worker)
?refresh=trueDeveloper API key or monitoring admin (collectionHolderRecomputesPerDay quota: Free **5**, Builder **25**, Pro **100**)
Active market watch (slow cadence)automatic when holder summary is older than COLLECTION_HOLDER_WATCH_STALE_SECONDS

Cache: Redis TTL ~10 minutes (COLLECTION_HOLDER_CACHE_TTL_SECONDS), stale serve up to ~15 minutes (X-Cache: STALE).

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

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

Auto-index on authenticated lookup (Sprint C2.7+)

When GET /v1/collections/:address, GET /v1/collections/:address/market, or related market read routes would return **404** COLLECTION_NOT_FOUND, OwlViz instead **registers** the collection and queues **one** background index job when the request includes a valid developer API key or monitoring admin bearer token.

ClientUnknown collection behavior
Anonymous**404** COLLECTION_NOT_FOUND with sign-in / API key guidance (no upstream index work)
Developer API keyOne-shot index, then normal response (X-Collection-Auto-Index: queued on first queue)
Signed-in dashboardSession BFF (GET /api/developer/collections/:address) — same one-shot semantics

Already registered collections never re-index on read. Concurrent lookups for the same address dedupe to a single queue attempt. Plan quotas: collectionAutoIndexesPerDay per tier (Free **10**, Builder **50**, Pro **200**, UTC day).

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,
    "indexedCount": 320,
    "expectedCount": 10000,
    "coverage": "partial",
    "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.
ORBIS_API_BASE_URLOrbis Public API base (default https://www.orbisonsol.io/api/mp-public)
ORBIS_API_KEYOrbis API key (X-API-Key header). **Required for live Orbis floors** — all mp-public endpoints return HTTP 401 without a key. When omitted, Orbis is auto-disabled even if listed in MARKET_ENABLED_PROVIDERS. Request access via [Orbis developer API](https://www.orbisonsol.io/marketplace/developer).
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,orbis)

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

Query parameters

QueryValuesDescription
scopeprovider_only (default), provider, magic_eden, tensor, orbis, aggregated, all_marketsProvider listing mode. Magic Eden aggregated uses ME listing aggregation; Tensor always reads active listings sorted by PriceAsc (limit=1); Orbis reads collection stats from the Orbis Public API.

Response statuses

For multi-provider responses, top-level status, floor, listingCount, and provider mirror **aggregate.bestFloor** (same Orbis/Tensor-first, Magic Eden fallback rule). Per-marketplace detail remains in sources[]. Single-provider responses behave as before for that provider.

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).

Orbis collection identity

Orbis resolves collection identity in strict order — callers never pass an Orbis pathname/slug:

1. **Verified on-chain mint** — GET /api/mp-public?action=collection&collectionAddress={collectionAddress}.

2. **Sample mint fallback** — indexed or Helius-discovered asset mint via GET /api/mp-public?action=listing&mint=…, then collection stats from the listing payload or a follow-up collection lookup.

Orbis reports stats.floorPrice in SOL (converted to lamports in OwlViz). A missing or zero floor becomes no_listings (never 0 lamports). Without ORBIS_API_KEY, the Orbis source returns unavailable with a configuration message.

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

Example (multi-provider):

json
{
  "data": {
    "address": "AxFuniPo7RaDgPH6Gizf4GZmLQFc4M5ipckeeZfkrPNn",
    "status": "success",
    "floor": { "sol": 3.75, "lamports": "3750000000" },
    "listingCount": 410,
    "observedAt": "2026-09-08T16:00:01.000Z",
    "scope": "provider_only",
    "provider": {
      "id": "tensor",
      "symbol": "degods",
      "sampleMint": "Cuyvxd6G3fmKE6qNFy7UdjHYCxfoMcuCizRvJxX1pC9y",
      "attribution": {
        "id": "tensor",
        "name": "Tensor",
        "url": "https://tensor.trade"
      }
    },
    "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"
      },
      {
        "status": "unavailable",
        "floor": null,
        "listingCount": null,
        "observedAt": "2026-09-08T16:00:01.000Z",
        "provider": { "id": "orbis", "symbol": null, "sampleMint": "...", "attribution": { "id": "orbis", "name": "Orbis", "url": "https://www.orbisonsol.io" } },
        "freshness": "fresh",
        "message": "Orbis provider is disabled because ORBIS_API_KEY is not configured."
      }
    ],
    "provenance": {
      "bestFloorProviderId": "tensor",
      "method": "lowest_fresh_lamports",
      "comparedProviders": ["magic_eden", "tensor", "orbis"]
    },
    "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

  • **aggregate.bestFloor** — Lowest **fresh** lamports among **Orbis and Tensor**. **Magic Eden** is used only when neither Orbis nor Tensor has a fresh floor (fresh ME fallback, then stale Orbis/Tensor, then stale ME). Per-provider rows in sources[] are unchanged; provenance.bestFloorProviderId identifies which provider supplied the best floor; provenance.comparedProviders lists Orbis and Tensor when present (primary comparison set).
  • Floors are never invented as 0 when missing — use null and the appropriate status (no_listings, unmapped, unavailable).
  • 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 (Orbis/Tensor fresh minimum, Magic Eden fallback — see [Best floor policy](#best-floor-policy)).
  • **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", "orbis"],
      "contributions": { "magic_eden": 4, "tensor": 4, "orbis": 0 },
      "current": { "magic_eden": "success", "tensor": "success", "orbis": "unavailable" }
    }
  }
}

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_WRITESfalseWhen false, legacy admin-token watch path returns 403; portal API keys can still create watches when keyed self-serve is enabled
MONITORING_ADMIN_TOKEN—Ops Bearer override 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,orbis) plus TENSOR_API_KEY and/or ORBIS_API_KEY on the **worker** service. When an API key is present, that provider 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 the corresponding key, Tensor/Orbis are omitted from ticks and lastTick.providerStatuses will not include them.

Watch endpoints

MethodPathAuth
POST/v1/market-watchesAPI key or admin Bearer write
GET/v1/market-watchespublic; keyed lists owned watches only
GET/v1/market-watches/:idpublic; keyed requires ownership
GET/v1/collections/:address/market-watchpublic
PATCH/v1/market-watches/:idAPI key or admin Bearer write (refreshNow requires **admin** only)
DELETE/v1/market-watches/:idAPI key or admin Bearer write
POST/v1/market-watches/:id/tick**Monitoring admin token only** (scheduled worker handles cadence)

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. **Webhook delivery** for new events is available via POST /v1/webhooks (Sprint 6.1 — see [webhooks.md](../webhooks.md)).

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. unavailable → success) 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.

**Sprint 6.1 (webhooks):** market change events can be subscribed via POST /v1/webhooks (see [webhooks.md](../webhooks.md)). Still out of scope: Discord/Slack pings, new event types, pricing, analytics, new providers.

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