Errors & rate limits
All API errors use a consistent JSON envelope:
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable message."
}
}Unexpected server errors may use a legacy Fastify shape without error.code. Client code should handle non-2xx responses defensively.
Stack traces are never returned to clients.
Rate limiting
When Upstash Redis is configured, public asset and collection routes are rate limited using fixed-window counters in Redis.
**Commercial Sprint C2** applies plan-based limits for keyed requests. See [developer-plans.md](./developer-plans.md) for the full matrix.
**C2.5 durable metering:** successful keyed requests on rate-limited public routes also increment daily Postgres counters (api_usage_daily) for portal and operator visibility. Metering is **fail-open** (a write failure never blocks the API). Monthly totals are **not** the same as RPM — they are attribution counters only, not billing.
Modes
| Mode | Bucket key | Limits |
|---|---|---|
**Anonymous** (no ov_live_ key) | Hashed client IP | Free tier — same as free plan |
**Keyed** (Authorization: Bearer ov_live_…) | API key ID | developer_accounts.plan_id entitlements |
Plan defaults (60s window)
| plan_id | Global RPM | Expensive RPM |
|---|---|---|
free | 120 | 60 |
builder | 600 | 300 |
pro | 2000 | 1000 |
Free keyed limits match anonymous IP limits. Builder and Pro raise RPM when a valid API key is presented.
Environment overrides
| Variable | Default | Meaning |
|---|---|---|
RATE_LIMIT_WINDOW_SECONDS | 60 | Window length in seconds |
RATE_LIMIT_MAX | 120 | Anonymous + Free keyed global RPM |
RATE_LIMIT_ROUTE_MAX | 60 | Anonymous + Free keyed expensive-route RPM |
PLAN_BUILDER_GLOBAL_RPM | 600 | Optional Builder global override |
PLAN_BUILDER_EXPENSIVE_RPM | 300 | Optional Builder expensive override |
PLAN_PRO_GLOBAL_RPM | 2000 | Optional Pro global override |
PLAN_PRO_EXPENSIVE_RPM | 1000 | Optional Pro expensive override |
Production asset GET /v1/assets/:mint exposes X-RateLimit-Limit: 60 for anonymous/Free keyed traffic (the tighter of global + route limits is enforced).
Tiers
**Expensive** (global + per-route counters):
GET /v1/assets/:mint(+/health,/inspection,/media)POST /v1/assets/:mint/media/recoverGET /v1/collections/:address(+/assets,/health,/holders,/market)POST /v1/collections/:address/index
**Lightweight** (global counter only):
GET /v1/assets/:mint/snapshot,/snapshotsGET /v1/collections/:address/indexGET /v1/collection-index-jobs/:jobId
Exempt:
GET /health
**Not rate limited** (no matching rule):
- Monitor, event, and webhook routes
Response headers
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests in the current window (merged limit) |
X-RateLimit-Remaining | Remaining requests |
X-RateLimit-Reset | Unix timestamp when the window resets |
Retry-After | Seconds to wait (429 only) |
429 envelope
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Please retry after the window resets."
}
}Fail-open
If Redis errors during a rate limit check, the request is **allowed** and a warning is logged (RATE_LIMIT_CHECK_FAILED). Rate limiting is disabled entirely when Redis is not configured.
Client IP identity (anonymous only)
Rate limit keys hash the resolved client IP. X-Real-IP is honored **only** when the socket peer is in TRUST_PROXY_CIDRS (includes 100.0.0.0/8 on Railway). Untrusted peers ignore spoofed X-Real-IP. **X-Forwarded-For is not used** for rate-limit identity.
Keyed requests ignore IP for rate limiting — the API key ID is the bucket identifier.
Error catalog
| HTTP | Code | Meaning | Developer action |
|---|---|---|---|
| 400 | INVALID_ASSET_ID | Mint is not a valid Solana public key | Fix the address parameter |
| 400 | INVALID_COLLECTION_ADDRESS | Collection address invalid | Fix the address parameter |
| 400 | INVALID_QUERY_PARAMETER | Bad query value | Fix query string |
| 400 | INVALID_MONITOR_TARGET | Monitor target invalid | Provide valid mint/address |
| 400 | INVALID_MONITOR_INTERVAL | Interval out of range | Use allowed interval |
| 400 | INVALID_EVENT_TYPE | Unknown event type for target | Use enum values from monitoring docs |
| 400 | INVALID_WEBHOOK_URL | Webhook URL blocked or malformed | Use HTTPS URL passing SSRF checks |
| 401 | INVALID_API_KEY | Invalid or revoked OwlViz API key | Create a new key or fix the Bearer header |
| 401 | DEVELOPER_KEY_REQUIRED | Forced refresh, indexing, or recovery without key/admin | Send Authorization: Bearer ov_live_… or admin token |
Portal API key management (session BFF on owlviz.dev — not the public api.owlviz.dev surface):
| HTTP | Code | Meaning | |
|---|---|---|---|
| 401 | UNAUTHORIZED | Sign in required | |
| 404 | PROJECT_NOT_FOUND / API_KEY_NOT_FOUND | Project or key not owned by the signed-in account | |
| 409 | ALREADY_REVOKED | Key was already revoked | |
| 401 | MONITORING_AUTH_REQUIRED | Missing/invalid Bearer token | Send Authorization: Bearer <private-beta-token> |
| 403 | MONITORING_WRITES_FORBIDDEN | Writes disabled on deployment | Use read APIs or request beta access |
| 404 | ASSET_NOT_FOUND | Asset not found upstream | Verify mint exists |
| 404 | SNAPSHOT_NOT_FOUND | No persisted snapshot | Wait for persistence or use live resolve |
| 404 | COLLECTION_NOT_FOUND | Collection not registered (anonymous lookup) | Sign in, use ov_live_ key, or POST .../index |
| 429 | COLLECTION_AUTO_INDEX_LIMIT_REACHED | Daily auto-index quota exhausted | Wait for UTC day rollover or upgrade plan |
| 404 | COLLECTION_NOT_INDEXED | Index not started | Start indexing |
| 404 | INDEX_JOB_NOT_FOUND | Job ID unknown | Verify job ID |
| 404 | MONITOR_NOT_FOUND | Monitor or event not found | Verify ID |
| 404 | WEBHOOK_NOT_FOUND | Webhook not found | Verify ID |
| 409 | INDEX_ALREADY_RUNNING | Active job + refresh=true | Poll existing job |
| 429 | RATE_LIMITED | Too many requests | Back off using Retry-After |
| 429 | MONITOR_LIMIT_REACHED | Max active monitors | Delete or pause monitors |
| 429 | MARKET_WATCH_LIMIT_REACHED | Max active market watches (plan or deployment cap) | Delete or pause watches |
| 429 | WEBHOOK_LIMIT_REACHED | Max webhook endpoints for plan | Delete endpoints or upgrade plan |
| 502 | UPSTREAM_ERROR | Provider failure | Retry with backoff |
| 502 | UPSTREAM_INVALID_RESPONSE | Provider returned invalid data | Retry later |
| 502 | WEBHOOK_DELIVERY_FAILED | Test delivery failed | Fix receiver endpoint |
| 503 | SERVICE_NOT_CONFIGURED | Resolver not configured | Contact operator |
| 503 | PERSISTENCE_UNAVAILABLE | Database not configured | Enable persistence or use live routes |
| 503 | API_KEY_AUTH_UNAVAILABLE | API key pepper or database not configured | Server configuration issue |
| 503 | MONITORING_WRITES_NOT_CONFIGURED | Writes enabled but no admin token | Server configuration issue |
| 504 | UPSTREAM_TIMEOUT | Provider timeout | Retry with backoff |
CORS
When CORS_ALLOWED_ORIGINS is set, browser requests must originate from an allowed origin. Exposed headers include rate limit and cache headers listed above.