{"openapi":"3.1.0","info":{"title":"Pegana API","description":"The peg-risk oracle for Solana. Read real-time peg state, history, alerts, and delivery health across 22 active mainnet assets across 6 classes — 8 LSTs (jitoSOL, mSOL, bSOL, INF, JupSOL, bbSOL, hyloSOL, hyloSOL+), 3 fiat stables (USDC, USDT, PYUSD), 2 CDP stables (USDS, hyUSD), 1 delta-neutral (USDe), 7 yield-bearing (USDY, sUSD, syrupUSDC, sUSDe, ONyc, sHYUSD, pbUSDC), and 1 synthetic-leveraged (xSOL, monitoring-only per ADR-0015). 5 Phase 0 additions (JupUSD, JLP, EURC, dzSOL, vSOL) deferred per ADR-0002. Public read endpoints require no API key. User-scoped /v1/me/* routes require a JWT obtained via Telegram Login. Every state transition emits a public receipt at /v1/audit/{id} with the methodology version, frozen inputs, and an on-chain SPL Memo commit (SAS deferred per ADR-0004 — SPL Memo gives 90% of the value).","contact":{"name":"Rafael Souza","email":"raffxweb3@gmail.com"},"license":{"name":"MIT","identifier":"MIT"},"version":"0.1.0"},"servers":[{"url":"https://api.pegana.xyz","description":"Production"}],"paths":{"/":{"get":{"tags":["Health"],"operationId":"root","responses":{"200":{"description":"Endpoint index + version","content":{"application/json":{"schema":{}}}}}}},"/healthz":{"get":{"tags":["Health"],"operationId":"live","responses":{"200":{"description":"Liveness probe — process is up","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LiveResponse"}}}}}}},"/readyz":{"get":{"tags":["Health"],"operationId":"ready","responses":{"200":{"description":"All downstream deps healthy","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadyResponse"}}}},"503":{"description":"DB, Redis, or snapshot age unhealthy","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadyResponse"}}}}}}},"/v1/alerts":{"get":{"tags":["Alerts"],"operationId":"list","parameters":[{"name":"asset","in":"query","description":"Restrict to a single asset symbol.","required":false,"schema":{"type":"string"}},{"name":"class","in":"query","description":"Restrict to a single asset class.","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Page size, must be in `[1, 500]`. Default `50`. Out-of-range\nvalues are rejected with 400 rather than silently clamped.","required":false,"schema":{"type":"integer","format":"int64"}},{"name":"since","in":"query","description":"Inclusive lower bound on `detected_at`. Defaults to now − 7 days.","required":false,"schema":{"type":"string","format":"date-time"}}],"responses":{"200":{"description":"Global alert feed, newest first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertRow"}}}}},"400":{"description":"Invalid ?limit= (must be 1..=500)"}}}},"/v1/assets":{"get":{"tags":["Assets"],"operationId":"list","parameters":[{"name":"class","in":"query","description":"Filter by asset class (e.g. `lst`, `stable_fiat`, `stable_cdp`,\n`stable_yield`, `synthetic_leverage`). Case-sensitive match against\nthe engine's asset_class enum.","required":false,"schema":{"type":"string"}},{"name":"peg","in":"query","description":"Filter by peg target (e.g. `SOL`, `USD`, `BRL`). Matches the asset's\ndeclared peg, not its observed value.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"All active assets with their latest snapshot","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AssetCard"}}}}}}}},"/v1/assets/{symbol}":{"get":{"tags":["Assets"],"operationId":"detail","parameters":[{"name":"symbol","in":"path","description":"Asset symbol — case-sensitive (e.g. `jitoSOL`, `USDC`, `hyUSD`)","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Latest snapshot for the asset","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AssetCard"}}}},"404":{"description":"Asset symbol unknown or inactive"}}}},"/v1/assets/{symbol}/history":{"get":{"tags":["Assets"],"operationId":"history","parameters":[{"name":"symbol","in":"path","description":"Asset symbol — case-sensitive","required":true,"schema":{"type":"string"}},{"name":"bucket","in":"query","description":"`raw` (default) samples directly from `discount_snapshots`; `1m`\nreads the 1-minute continuous aggregate so longer ranges stay cheap.\nAny other value is rejected with 400 rather than silently falling\nback to `raw` (QA B-020).","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","description":"Inclusive lower bound on `ts`. Defaults to now − 24 hours.","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","description":"Inclusive upper bound on `ts`. Defaults to now.","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"limit","in":"query","description":"Page size, clamped to `[1, 5000]`. Default `500`.","required":false,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Time series of discount values, newest first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/HistoryPoint"}}}}},"400":{"description":"Invalid ?bucket= (must be one of raw|1m)"}}}},"/v1/assets/{symbol}/state":{"get":{"tags":["Assets"],"operationId":"state","parameters":[{"name":"symbol","in":"path","description":"Asset symbol — case-sensitive","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Current state + transition timestamp","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StateResp"}}}},"404":{"description":"No snapshots have been recorded for this asset yet"}}}},"/v1/audit":{"get":{"tags":["audit"],"operationId":"index","parameters":[{"name":"limit","in":"query","description":"Page size. Default 50, clamped to 100 per ADR-0014.","required":false,"schema":{"type":"integer","format":"int32","minimum":0}},{"name":"exclude_pegged","in":"query","description":"When true (default), filter out `PEGGED` end-state rows. PEGGED is\nthe \"recovered\" state — usually noise from the index perspective.","required":false,"schema":{"type":"boolean"}},{"name":"state","in":"query","description":"Server-side filter on the to_state value. Closes AC42 — the\nprevious client-side filter on the web/audit page was correct in\nshape but constrained to whatever batch the index returned (default\n50 rows). With server filtering the full limit applies to matching\nrows. Valid values: PEGGED, DRIFT, DEPEG, CRITICAL, BLACK_SWAN.\nAny other value yields HTTP 400 with `error: invalid_state`.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Index of recent audits, newest first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AuditIndexRow"}}}}},"400":{"description":"Invalid ?state= value (must be one of PEGGED|DRIFT|DEPEG|CRITICAL|BLACK_SWAN)"}}}},"/v1/audit.csv":{"get":{"tags":["audit"],"operationId":"csv","parameters":[{"name":"from","in":"query","description":"Lower bound (inclusive) on `detected_at`. ISO-8601 / RFC-3339.","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","description":"Upper bound (inclusive) on `detected_at`. Max 90 days from `from`\nper ADR-0014.","required":true,"schema":{"type":"string","format":"date-time"}},{"name":"asset","in":"query","description":"Optional asset symbol filter (e.g. `USDe`). Recommended for large\nranges.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Streaming CSV export (chunked transfer encoding); max 50k rows per ADR-0014"},"400":{"description":"Invalid date range (> 90 days per ADR-0014) or missing parameters"}}}},"/v1/audit/{alert_id}":{"get":{"tags":["audit"],"summary":"`GET /v1/audit/{alert_id}` — single receipt with ADR-0006 four-state\ndiscrimination.","description":"Returns `Response` (not `Json`) so each status path can carry distinct\nheaders and body without fighting axum's `Result<T, E>` → IntoResponse\nconversion (which forces non-2xx into the `Err` arm).","operationId":"detail","parameters":[{"name":"alert_id","in":"path","description":"Alert UUID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Receipt JSON (alert + evidence wrapper)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuditDetailResponse"}}}},"202":{"description":"Evidence persistence pending (ADR-0006); body carries retry_after_seconds"},"400":{"description":"Invalid UUID format"},"404":{"description":"Unknown alert_id (no row in alert_evidence or alert_evidence_pending)"},"410":{"description":"Evidence persistence failed permanently (ADR-0006); receipt will never materialize"}}}},"/v1/audit/{alert_id}/onchain":{"get":{"tags":["audit"],"operationId":"onchain","parameters":[{"name":"alert_id","in":"path","description":"Alert UUID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"On-chain commit pointer","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OnchainResponse"}}}},"400":{"description":"Invalid UUID format"},"404":{"description":"Not anchored — stays 404 across ALL non-committed states by design (clients such as `pegana-replay --verify-onchain` treat 404 as 'skip the on-chain check'). Body `error` discriminates the reason: `not_applicable` (cost-gated, never anchored — ADR-0004), `not_committed_yet` (in-flight, queued, or unknown alert), `retry_exhausted`, `wallet_drained`, or `persistence_failed` (ADR-0006 dead-letter terminal — the same state /detail returns as 410)."}}}},"/v1/audit/{alert_id}/replay-bundle":{"get":{"tags":["audit"],"operationId":"replay_bundle","parameters":[{"name":"alert_id","in":"path","description":"Alert UUID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Replay bundle JSON (Content-Disposition: attachment)"},"400":{"description":"Invalid UUID format"},"404":{"description":"No replay artifact (DRIFT alerts) or unknown alert_id"}}}},"/v1/auth/logout":{"post":{"tags":["Auth"],"summary":"`POST /v1/auth/logout` — revoke the caller's session JWT (by `jti`).\nAccepts even an already-expired token so a stale tab can still log out.","operationId":"logout","responses":{"204":{"description":"Session revoked"},"400":{"description":"Missing or malformed bearer token"},"401":{"description":"Caller is not authenticated"}},"security":[{"telegram_jwt":[]}]}},"/v1/auth/magic/consume":{"post":{"tags":["Auth"],"summary":"`POST /v1/auth/magic/consume` — public endpoint the web client calls\nwhen it lands on `/auth/magic?nonce=…`. Atomically marks the nonce\nconsumed AND returns the telegram_id in a single UPDATE … RETURNING\n(race-free; two concurrent consumers can't both succeed). On hit, mints\na 7-day session JWT — same path as Login Widget.","description":"Failure modes:\n  - 400 if the body fails to parse.\n  - 401 if the nonce is unknown, already consumed, or expired.\n  - 500 if the JWT insert fails (DB issue).","operationId":"consume_magic","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ConsumeMagicRequest"}}},"required":true},"responses":{"200":{"description":"Session JWT + user view","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}},"401":{"description":"Nonce invalid, already consumed, or expired"}}}},"/v1/auth/magic/mint":{"post":{"tags":["Auth"],"summary":"`POST /v1/auth/magic/mint` — internal endpoint the bot calls when a user\nruns `/web`. Authenticates via `x-internal-secret` (constant-time\ncompare against `BOT_INTERNAL_SECRET`). Inserts a fresh nonce row and\nreturns it. No JWT is issued here — that happens on consume.","description":"Failure modes:\n  - 401 if the header is missing or wrong.\n  - 400 if the body fails to parse.\n  - 500 if the DB insert fails.","operationId":"mint_magic","parameters":[{"name":"x-internal-secret","in":"header","description":"Shared secret (BOT_INTERNAL_SECRET); bot-only, not a user bearer token","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MintMagicRequest"}}},"required":true},"responses":{"200":{"description":"Fresh magic-link nonce","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MintMagicResponse"}}}},"401":{"description":"x-internal-secret missing or wrong"}}}},"/v1/auth/telegram":{"post":{"tags":["Auth"],"summary":"`POST /v1/auth/telegram` — exchange a verified Telegram Login Widget\npayload for a 7-day session JWT. Unauthenticated; the HMAC over the\npayload (signed with SHA256(BOT_TOKEN)) is the credential.","operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginPayload"}}},"required":true},"responses":{"200":{"description":"Session JWT + user view","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}},"401":{"description":"Login Widget HMAC invalid or auth_date too old"}}}},"/v1/me":{"get":{"tags":["Me"],"summary":"`GET /v1/me` — the authenticated user's profile + digest preferences.","operationId":"me","responses":{"200":{"description":"Current user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Me"}}}},"401":{"description":"Caller is not authenticated"}},"security":[{"telegram_jwt":[]}]}},"/v1/me/alerts":{"get":{"tags":["Me"],"summary":"`GET /v1/me/alerts` — alerts delivered to the caller's subscriptions,\nnewest first. Optional `asset`, `limit` (1..500), and `since` filters.","operationId":"list_alerts","parameters":[{"name":"asset","in":"query","description":"Filter to a single asset symbol","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Max rows, 1..500 (default 50)","required":false,"schema":{"type":"integer","format":"int64"}},{"name":"since","in":"query","description":"RFC3339 lower bound on attempted_at (default: 30 days ago)","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Delivered alerts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/AlertDelivery"}}}}},"401":{"description":"Caller is not authenticated"}},"security":[{"telegram_jwt":[]}]}},"/v1/me/preferences":{"patch":{"tags":["Me"],"summary":"`PATCH /v1/me/preferences` — update locale (`en`|`pt-BR`) and/or daily\ndigest settings. Omitted fields are left unchanged.","operationId":"preferences","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PreferencesPatch"}}},"required":true},"responses":{"204":{"description":"Preferences updated"},"400":{"description":"Invalid locale or digest_hour_utc out of 0..23"},"401":{"description":"Caller is not authenticated"}},"security":[{"telegram_jwt":[]}]}},"/v1/me/subs":{"get":{"tags":["Subscriptions"],"summary":"`GET /v1/me/subs` — the caller's alert subscriptions (active first).","operationId":"list_subs","responses":{"200":{"description":"Subscription list","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SubView"}}}}},"401":{"description":"Caller is not authenticated"}},"security":[{"telegram_jwt":[]}]},"post":{"tags":["Subscriptions"],"summary":"`POST /v1/me/subs` — create (or re-activate) an alert subscription for an\nasset at a bps threshold. Idempotent on (asset, threshold, channel).","operationId":"create_sub","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSub"}}},"required":true},"responses":{"201":{"description":"Subscription created or re-activated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SubView"}}}},"400":{"description":"threshold_bps out of 1..10000"},"401":{"description":"Caller is not authenticated"}},"security":[{"telegram_jwt":[]}]}},"/v1/me/subs/{asset}":{"delete":{"tags":["Subscriptions"],"summary":"Soft-delete by default; hard-delete with `?hard=true`. When `?only_id=N`\nis supplied, scopes to a single row (used by the web console where the UI\nknows which specific row the user clicked). Without `only_id`, all rows\nfor this user+asset are affected (mirrors `/unsubscribe <asset>` in bot).","operationId":"delete_sub","parameters":[{"name":"asset","in":"path","description":"Asset symbol, e.g. USDC","required":true,"schema":{"type":"string"}},{"name":"hard","in":"query","description":"true → permanently DELETE; default soft-delete (is_active=false)","required":false,"schema":{"type":"boolean"}},{"name":"only_id","in":"query","description":"Scope the deletion to a single subscription row","required":false,"schema":{"type":"integer","format":"int64"}}],"responses":{"204":{"description":"Subscription(s) deleted/deactivated"},"401":{"description":"Caller is not authenticated"},"404":{"description":"No matching subscription"}},"security":[{"telegram_jwt":[]}]},"patch":{"tags":["Subscriptions"],"summary":"`PATCH /v1/me/subs/{asset}` — update threshold / active state for an\nasset's subscription(s). `?only_id=N` scopes to a single row.","operationId":"patch_sub","parameters":[{"name":"asset","in":"path","description":"Asset symbol, e.g. USDC","required":true,"schema":{"type":"string"}},{"name":"only_id","in":"query","description":"Scope the update to a single subscription row","required":false,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchSub"}}},"required":true},"responses":{"204":{"description":"Subscription(s) updated"},"400":{"description":"threshold_bps out of 1..10000"},"401":{"description":"Caller is not authenticated"},"404":{"description":"No matching subscription"}},"security":[{"telegram_jwt":[]}]}},"/v1/me/webhooks":{"get":{"tags":["Webhooks"],"summary":"`GET /v1/me/webhooks` — the caller's active webhook subscriptions.","operationId":"list_webhooks","responses":{"200":{"description":"Active webhook subscriptions","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Webhook"}}}}},"401":{"description":"Caller is not authenticated"}},"security":[{"telegram_jwt":[]}]},"post":{"tags":["Webhooks"],"summary":"`POST /v1/me/webhooks` — register an Ed25519-signed webhook for an asset.\nURL is SSRF- and length-checked; idempotent on (url, asset).","operationId":"create_webhook","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhook"}}},"required":true},"responses":{"201":{"description":"Webhook created or re-activated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"}}}},"400":{"description":"URL rejected (SSRF/length) or threshold out of range"},"401":{"description":"Caller is not authenticated"}},"security":[{"telegram_jwt":[]}]}},"/v1/me/webhooks/{id}":{"delete":{"tags":["Webhooks"],"summary":"`DELETE /v1/me/webhooks/{id}` — deactivate a webhook subscription\n(soft-delete: is_active=false, keeping its delivery history).","operationId":"delete_webhook","parameters":[{"name":"id","in":"path","description":"Webhook subscription id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Webhook deactivated"},"401":{"description":"Caller is not authenticated"}},"security":[{"telegram_jwt":[]}]},"patch":{"tags":["Webhooks"],"summary":"`PATCH /v1/me/webhooks/{id}` — partial update of a subscription\nkeeping its `id` (and so its `webhook_deliveries` history) intact.","description":"The previous \"delete + re-create\" workflow orphaned the audit\ntrail under a new id. This endpoint keeps continuity for\ndashboards and replay.\n\nReturns the full webhook row after the update. 404 if the id\nbelongs to a different user (no existence-leak).","operationId":"patch_webhook","parameters":[{"name":"id","in":"path","description":"Webhook subscription id","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchWebhook"}}},"required":true},"responses":{"200":{"description":"Updated webhook","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Webhook"}}}},"400":{"description":"Validation error (bad url, threshold out of range)"},"401":{"description":"Caller is not authenticated"},"404":{"description":"Webhook subscription not found under caller's user"}},"security":[{"telegram_jwt":[]}]}},"/v1/me/webhooks/{id}/deliveries":{"get":{"tags":["Webhooks"],"summary":"`GET /v1/me/webhooks/{id}/deliveries?since=&limit=` — paged audit\ntrail of every delivery attempt against this subscription, ordered\nnewest first. Includes successes (`error = null`), failures, and\n`is_test = true` rows from `/test`.","description":"Backed by `webhook_deliveries` (migration 0023). Both the\nproduction fan-out (`dispatcher-rs`) and the single-shot\n`/test`+`/replay` path write here, so this is the single operator\nview of receiver health.","operationId":"list_deliveries","parameters":[{"name":"id","in":"path","description":"Webhook subscription id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"since","in":"query","description":"Lower bound unix seconds. Defaults to 7d ago.","required":false,"schema":{"type":"integer","format":"int64"}},{"name":"limit","in":"query","description":"Max rows, clamped to 1..1000. Default 100.","required":false,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Delivery attempts newest first","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/DeliveryRow"}}}}},"401":{"description":"Caller is not authenticated"},"404":{"description":"Webhook subscription not found under caller's user"}},"security":[{"telegram_jwt":[]}]}},"/v1/me/webhooks/{id}/replay":{"post":{"tags":["Webhooks"],"summary":"`POST /v1/me/webhooks/{id}/replay?since=<unix_seconds>&limit=<n>` —\nre-attempt delivery of the dead-letter rows for this subscription\ninside the window. Modeled on Svix's \"Replay Missing\".","description":"Successful re-deliveries are NOT removed from `webhook_dead_letter`\n— the audit trail stays put. Repeated success on the same row is\nharmless (the receiver's own dedup on `x-pegana-event-id` makes the\nsecond delivery a no-op).","operationId":"replay_webhook","parameters":[{"name":"id","in":"path","description":"Webhook subscription id","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"since","in":"query","description":"Lower bound unix seconds. Defaults to 24h ago.","required":false,"schema":{"type":"integer","format":"int64"}},{"name":"limit","in":"query","description":"Max DLQ rows to retry, clamped to 1..500. Default 50.","required":false,"schema":{"type":"integer","format":"int64"}}],"responses":{"200":{"description":"Per-row delivery results + tallies","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplaySummary"}}}},"401":{"description":"Caller is not authenticated"},"404":{"description":"Webhook subscription not found under caller's user"}},"security":[{"telegram_jwt":[]}]}},"/v1/me/webhooks/{id}/test":{"post":{"tags":["Webhooks"],"summary":"`POST /v1/me/webhooks/{id}/test` — send a synthetic signed payload\nto the registered URL and return what happened. Mirrors Stripe's\n\"Send test webhook\" button. The synthetic event carries the\n`x-pegana-test: true` header so subscribers can guard prod handlers\n(e.g. skip downstream order-fill on test deliveries).","description":"No retry. One POST. The caller is operator-triggered, not engine-\ntriggered, so retries would lie about the receiver's true health.","operationId":"test_webhook","parameters":[{"name":"id","in":"path","description":"Webhook subscription id","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Delivery attempt result","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryResult"}}}},"401":{"description":"Caller is not authenticated"},"404":{"description":"Webhook subscription not found under caller's user"}},"security":[{"telegram_jwt":[]}]}},"/v1/meta/webhook-ips":{"get":{"tags":["Health"],"summary":"GET /v1/meta/webhook-ips — IP allowlist hint for webhook subscribers.","description":"Pattern lifted from Linear (`6 static IPs` page) and GitHub\n(`GET /meta` with `hooks` field). Subscribers behind enterprise\nfirewalls need to know which source IPs to trust; without this\nthey either disable signature-based trust or refuse our deliveries.\n\nSource of truth is the `PEGANA_WEBHOOK_EGRESS_IPS` env var\n(comma-separated). Default falls back to the Hetzner production\nbox's static IP. When we add a second box, set the env to\n`49.12.240.164,<new-ip>` — no code change required.","operationId":"webhook_ips","responses":{"200":{"description":"Webhook egress IP allowlist","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookIpsResponse"}}}}}}},"/v1/meta/webhook-keys":{"get":{"tags":["Health"],"summary":"GET /v1/meta/webhook-keys — active Ed25519 public keys for verifying\ninbound webhook signatures.","description":"Lifted from the GitHub pattern (`GET /meta` with `ssh_keys`).\nDuring key rotation we publish BOTH the outgoing primary AND the\nincoming secondary here, so receivers can pre-trust the new key\nbefore we cut over. See `pegana_common::webhook_sign` for the\nfull rotation playbook.","operationId":"webhook_keys","responses":{"200":{"description":"Active webhook-signer public keys","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookKeysResponse"}}}}}}},"/v1/methodology/current":{"get":{"tags":["methodology"],"operationId":"current","responses":{"200":{"description":"Current methodology version + lifecycle status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MethodologyCurrentResponse"}}}},"500":{"description":"Database error"}}}},"/v1/stats":{"get":{"tags":["Stats"],"operationId":"summary","responses":{"200":{"description":"Aggregate counters + delivery rollup","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StatsResponse"}}}}}}},"/v1/ws":{"get":{"tags":["WebSocket"],"summary":"`GET /v1/ws` — upgrade to a WebSocket carrying the live peg-state stream.","description":"Not a JSON endpoint: a successful handshake returns `101 Switching\nProtocols`. After upgrade the server pushes `{op:\"update\", ...}` and\n`{op:\"heartbeat\", ts}` frames; the client may send `{op:\"ping\"}` (→ `pong`)\nand `{op:\"subscribe\"|\"unsubscribe\", assets:[...]}`. Browsers must present an\nallowlisted `Origin`; server-to-server tools may omit it.","operationId":"ws_handler","responses":{"101":{"description":"Switching Protocols — WebSocket established"},"403":{"description":"Origin present but not on the allowlist"},"429":{"description":"Per-IP connection cap reached"}}}}},"components":{"schemas":{"AlertDelivery":{"type":"object","required":["alert_id","asset","from_state","to_state","status","channel","attempted_at"],"properties":{"ack_reason":{"type":["string","null"]},"acknowledged_at":{"type":["string","null"],"format":"date-time"},"alert_id":{"type":"string","format":"uuid"},"asset":{"type":"string"},"attempted_at":{"type":"string","format":"date-time"},"channel":{"type":"string"},"from_state":{"type":"string"},"status":{"type":"string"},"to_state":{"type":"string"}}},"AlertRow":{"type":"object","required":["id","asset","class","from_state","to_state","detected_at"],"properties":{"asset":{"type":"string"},"class":{"type":"string"},"detected_at":{"type":"string","format":"date-time"},"dispatched_at":{"type":["string","null"],"format":"date-time","description":"`None` while the dispatcher is still attempting delivery; populated\nonce at least one fan-out target accepted the alert."},"from_state":{"type":"string"},"id":{"type":"string","format":"uuid"},"to_state":{"type":"string"}}},"AssetCard":{"type":"object","required":["symbol","name","mint","class","peg_target","decimals"],"properties":{"class":{"type":"string"},"confidence":{"type":["string","null"],"description":"Pyth oracle confidence bucket: `high` | `medium` | `low` | `unknown`.\nScopes the PRICE-ORACLE confidence interval only (conf/price ratio), NOT\nmarket-quote / route-depth reliability. Omitted when no discount snapshot\nexists yet. Reflects the LATEST snapshot — an omitted value means \"no\ncurrent signal\" and MUST NOT be rendered as a positive bucket; the client\ngates on `updated_at` freshness before showing it (hardening H1/H9)."},"decimals":{"type":"integer","format":"int32"},"discount":{"type":["string","null"]},"intrinsic_usd":{"type":["string","null"]},"jitter_bps_24h":{"type":["string","null"],"description":"Peg-jitter scalar over the trailing 24h (BSRV-03): peak-to-trough\ndispersion of the discount, `max(max_discount) - min(min_discount)`,\nin the same signed-fraction units as `discount`. A cheap stability\nmeasure (always >= 0) computed server-side once per scan instead of\nevery client recomputing it from the averaged `series_24h`. Trailing\n24h, independent of the current-snapshot freshness bound. Omitted when\nno `discount_1m` rows exist in the window."},"market_usd":{"type":["string","null"]},"mint":{"type":"string"},"name":{"type":"string"},"peg_target":{"type":"string"},"series_24h":{"type":["array","null"],"items":{"type":"number","format":"double"}},"sol_per_lst":{"type":["string","null"],"description":"Stake-pool SOL per share for Sanctum LSTs (`rust_decimal`, serialized as\na JSON string). Omitted for non-LST assets. Reflects the LATEST intrinsic\nsnapshot — read together with `updated_at` for freshness. NAV decomposition\nis `intrinsic_usd = sol_per_lst × SOL/USD`."},"state":{"type":["string","null"]},"symbol":{"type":"string"},"thresholds":{"description":"Per-asset alert thresholds, served verbatim from `assets.thresholds`\nJSONB (BSRV-01). Two shapes pass through unchanged: bps-keyed\n`{\"drift_bps\",\"depeg_bps\",\"critical_bps\"}` for most assets, and the\nCDP collateral-ratio form `{\"cr_drift\",\"cr_depeg\",\"cr_critical\",\n\"cr_black_swan\"}` for hyUSD. This is the AUTHORITATIVE source the\nclient should key band-gauge / chart threshold lines / \"closest to\nbreaking\" sort off — replacing any hand-maintained client table. The\ncolumn is re-synced to the engine's calibrated `assets.toml` values by\nmigration `0042_resync_asset_thresholds`. Omitted only if the column\nis NULL (never, given the `NOT NULL` constraint) or fails to decode."},"updated_at":{"type":["string","null"],"format":"date-time"},"worst_abs_24h":{"type":["string","null"],"description":"Worst (largest-magnitude) absolute discount observed over the trailing\n24h (BSRV-02), in the same signed-fraction units as `discount`\n(e.g. `0.0123` = 123 bps). Computed as\n`max(greatest(abs(min_discount), abs(max_discount)))` over the\n`discount_1m` aggregate — discount is signed, so the abs is required to\ncatch the worst tick in EITHER direction. Trailing 24h, INDEPENDENT of\nthe current-snapshot 15-min freshness bound on `discount`. Omitted when\nno `discount_1m` rows exist in the window."}}},"AuditCsvRow":{"type":"object","description":"One row of the streaming CSV from `GET /v1/audit.csv`. axum-streams\nderives header + row encoding from `serde::Serialize`.","required":["alert_id","asset","class","from_state","to_state","discount","intrinsic_usd","market_usd","confidence","detected_at","methodology_version","assets_toml_sha256","receipt_sha256"],"properties":{"alert_id":{"type":"string"},"asset":{"type":"string"},"assets_toml_sha256":{"type":"string"},"class":{"type":"string"},"confidence":{"type":"string"},"detected_at":{"type":"string"},"discount":{"type":"string"},"from_state":{"type":"string"},"intrinsic_usd":{"type":"string"},"market_usd":{"type":"string"},"methodology_version":{"type":"string"},"onchain_tx_sig":{"type":["string","null"]},"receipt_sha256":{"type":"string"},"to_state":{"type":"string"}}},"AuditDetailResponse":{"type":"object","description":"Receipt envelope returned by `GET /v1/audit/:alert_id` when the\n`alert_evidence` row exists. The actual response body is built as\n`serde_json::Value`; this struct exists so utoipa can render an\napproximate schema in `/openapi.json`.","required":["alert","evidence"],"properties":{"alert":{},"evidence":{}}},"AuditIndexRow":{"type":"object","description":"One row of the `GET /v1/audit` index array.","required":["id","asset","from_state","to_state","detected_at","methodology_version","receipt_sha256"],"properties":{"asset":{"type":"string"},"detected_at":{"type":"string","format":"date-time"},"from_state":{"type":"string"},"id":{"type":"string"},"methodology_version":{"type":"string"},"onchain_tx_sig":{"type":["string","null"]},"receipt_sha256":{"type":"string"},"to_state":{"type":"string"}}},"ConsumeMagicRequest":{"type":"object","required":["nonce"],"properties":{"nonce":{"type":"string"}}},"CreateSub":{"type":"object","required":["asset","threshold_bps"],"properties":{"asset":{"type":"string"},"channel":{"type":"string"},"threshold_bps":{"type":"integer","format":"int32"}}},"CreateWebhook":{"type":"object","required":["url","asset","threshold_bps"],"properties":{"asset":{"type":"string"},"threshold_bps":{"type":"integer","format":"int32"},"url":{"type":"string"}}},"DeliveryHealth":{"type":"object","description":"Delivery-pipeline health rollup. Surfaced so the dashboard can show a\ndegraded badge when notifications are silently failing — historically\nMarkdownV2 escape bugs caused alert_deliveries.status='failed' rows to\npile up without any user-visible signal, breaking trust.","required":["sent_24h","failed_24h"],"properties":{"failed_24h":{"type":"integer","format":"int64"},"last_failure_error":{"type":["string","null"],"description":"Most recent failure error message (truncated to 200 chars) if any\nfailures landed in the last 24h. Useful for ops without forcing the\nfrontend to query a separate endpoint."},"sent_24h":{"type":"integer","format":"int64"}}},"DeliveryResult":{"type":"object","description":"Result of a single-shot delivery attempt. Returned as JSON to the\ncaller so the operator can see exactly what happened.","required":["elapsed_ms","event_id","delivery_id"],"properties":{"delivery_id":{"type":"string","description":"Fresh per attempt — correlate with receiver-side logs."},"elapsed_ms":{"type":"integer","description":"Wall-clock latency from request build to response received.","minimum":0},"error":{"type":["string","null"],"description":"`Some(error)` when the delivery did not return 2xx OR the\nrequest layer failed before sending."},"event_id":{"type":"string","description":"Stable id mirroring the body's `alert_id` (or a fresh UUID for\ntest-send synthetic events)."},"http_status":{"type":["integer","null"],"format":"int32","description":"HTTP status from the receiver. `None` if the request failed\nbefore getting a response (DNS, timeout, TLS, SSRF guard).","minimum":0}}},"DeliveryRow":{"type":"object","required":["alert_id","attempt","elapsed_ms","is_test","attempted_at"],"properties":{"alert_id":{"type":"string","format":"uuid"},"attempt":{"type":"integer","format":"int32"},"attempted_at":{"type":"string","format":"date-time"},"elapsed_ms":{"type":"integer","format":"int32"},"error":{"type":["string","null"]},"http_status":{"type":["integer","null"],"format":"int32","description":"`null` when the request never produced an HTTP response\n(DNS, TLS, timeout, SSRF guard rejection)."},"is_test":{"type":"boolean"}}},"HistoryPoint":{"type":"object","required":["ts","discount","state"],"properties":{"discount":{"type":"string","description":"EWMA-smoothed discount (the value the state machine uses)."},"discount_raw":{"type":["string","null"],"description":"Raw discount sample (`1 - market/intrinsic`) before EWMA smoothing.\n`None` for `bucket=1m` because the continuous aggregate\n`discount_1m` only stores `avg(discount_smooth)`. For `bucket=raw`,\npopulated from `discount_snapshots.discount_raw`."},"state":{"type":"string"},"ts":{"type":"string","format":"date-time"}}},"LiveResponse":{"type":"object","required":["status","uptime_secs"],"properties":{"status":{"type":"string","description":"Always \"ok\" when the binary is up."},"uptime_secs":{"type":"integer","format":"int64","description":"Seconds since the process started."}}},"LoginPayload":{"type":"object","required":["id","auth_date","hash"],"properties":{"auth_date":{"type":"integer","format":"int64"},"first_name":{"type":["string","null"]},"hash":{"type":"string"},"id":{"type":"integer","format":"int64"},"last_name":{"type":["string","null"]},"photo_url":{"type":["string","null"]},"username":{"type":["string","null"]}}},"LoginResponse":{"type":"object","required":["token","expires_at","user"],"properties":{"expires_at":{"type":"string","format":"date-time"},"token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserView"}}},"Me":{"type":"object","required":["telegram_id","locale","digest_enabled","digest_hour_utc"],"properties":{"digest_enabled":{"type":"boolean"},"digest_hour_utc":{"type":"integer","format":"int32"},"locale":{"type":"string"},"telegram_id":{"type":"integer","format":"int64"},"username":{"type":["string","null"]}}},"MethodologyCurrentResponse":{"type":"object","description":"Public methodology lifecycle pointer.\n\n`version`, `status`, and `fix_url` are the original (v1) shape — every\ncurrent consumer (web/app/api/methodology/route.ts type-checks only\n`version`+`status`) is forward-compatible with the additive fields below.","required":["version","status"],"properties":{"fix_url":{"type":["string","null"],"description":"Only populated when status = `broken`. Points at the `#lifecycle`\nsection of /methodology, which renders the broken-version banner."},"git_tag":{"type":["string","null"],"description":"Immutable git release tag, e.g. `methodology-v0.1.0`. Always present\nwhen the version table is populated; omitted only on an un-migrated DB."},"released_at":{"type":["string","null"],"description":"RFC3339 timestamp this version was released. The honest analogue of the\noften-requested \"adopted_at\" — there is no separate adoption column;\nrelease == adoption in this lifecycle (ADR-0003)."},"status":{"type":"string","description":"Lifecycle status from `methodology_versions.status` enum:\n`active | deprecated | broken`."},"status_reason":{"type":["string","null"],"description":"Operator note attached to a `deprecated`/`broken` transition, if any."},"superseded_by":{"type":["string","null"],"description":"Set when this version has been superseded by a newer one."},"version":{"type":"string","description":"Semver version of the currently-active methodology crate."}}},"MintMagicRequest":{"type":"object","required":["telegram_id"],"properties":{"telegram_id":{"type":"integer","format":"int64"}}},"MintMagicResponse":{"type":"object","required":["nonce","expires_at"],"properties":{"expires_at":{"type":"string","format":"date-time"},"nonce":{"type":"string"}}},"OnchainResponse":{"type":"object","description":"On-chain commit pointer.","required":["tx_sig","explorer_url","commit_status"],"properties":{"commit_status":{"type":"string","description":"Always `\"committed\"` on a 200 — present so a caller can branch on the\nsame `commit_status` vocabulary the 404 body uses (ADR-0004)."},"explorer_url":{"type":"string"},"tx_sig":{"type":"string"}}},"PatchSub":{"type":"object","properties":{"is_active":{"type":["boolean","null"]},"threshold_bps":{"type":["integer","null"],"format":"int32"}}},"PatchWebhook":{"type":"object","properties":{"is_active":{"type":["boolean","null"],"description":"Toggle active state without deleting the row. Useful for\npause-then-resume during receiver maintenance."},"threshold_bps":{"type":["integer","null"],"format":"int32","description":"Replace the alert threshold (1..10000 bps). Omit to leave\nunchanged."},"url":{"type":["string","null"],"description":"Replace the destination URL. SSRF + length guards re-applied;\nomit to leave unchanged."}}},"PreferencesPatch":{"type":"object","properties":{"digest_enabled":{"type":["boolean","null"]},"digest_hour_utc":{"type":["integer","null"],"format":"int32"},"locale":{"type":["string","null"]}}},"ReadyResponse":{"type":"object","required":["status","db","redis","sources"],"properties":{"db":{"type":"boolean","description":"SELECT 1 on Postgres succeeded."},"last_snapshot_age_secs":{"type":["integer","null"],"format":"int64","description":"Seconds since the most recent `discount_snapshots.ts`. `None` if no\nsnapshots exist yet."},"redis":{"type":"boolean","description":"PING on Redis returned PONG."},"sources":{"$ref":"#/components/schemas/SourceFreshness","description":"Per-source freshness so /status can render each row with its own\nhonest age instead of mirroring the global discount timestamp."},"status":{"type":"string","description":"\"ready\" if all downstream deps are healthy; \"not_ready\" otherwise."}}},"ReplaySummary":{"type":"object","required":["attempted","succeeded","failed","results"],"properties":{"attempted":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"results":{"type":"array","items":{"$ref":"#/components/schemas/DeliveryResult"}},"succeeded":{"type":"integer","minimum":0}}},"SourceFreshness":{"type":"object","properties":{"hylo_secs":{"type":["integer","null"],"format":"int64","description":"Seconds since the most recent `hylo_state_snapshots` row (Hylo\nExchange CR + reserve snapshot)."},"jupiter_secs":{"type":["integer","null"],"format":"int64","description":"Seconds since the most recent `market_snapshots` row. Jupiter is the\nprimary writer; dexscreener fallback also lands here."},"pyth_secs":{"type":["integer","null"],"format":"int64","description":"Seconds since the most recent `oracle_snapshots.publish_time` (Pyth\nSSE stream)."},"sanctum_secs":{"type":["integer","null"],"format":"int64","description":"Seconds since the most recent `intrinsic_snapshots` row written by\nthe Sanctum LST adapter (`source LIKE 'sanctum%'`). `None` means no\nrow yet — cold start, or the source has never come up."}}},"StateResp":{"type":"object","required":["asset","state","since","discount","intrinsic_usd","market_usd"],"properties":{"asset":{"type":"string"},"discount":{"type":"string"},"intrinsic_usd":{"type":"string"},"market_usd":{"type":"string"},"since":{"type":"string","format":"date-time","description":"Timestamp of the most recent transition into the current state.\nEquals the current snapshot timestamp if the engine has only ever\nobserved one state for this asset."},"state":{"type":"string"}}},"StatsResponse":{"type":"object","required":["assets_tracked","assets_in_drift","alerts_24h","by_state","delivery_health"],"properties":{"alerts_24h":{"type":"integer","format":"int64"},"assets_in_drift":{"type":"integer","format":"int64","description":"Count of active assets whose most recent state is `DRIFT`. Derived from\n`by_state` so it never disagrees, but surfaced top-level so the home/\ntrust strip can render a single risk number without parsing the map."},"assets_tracked":{"type":"integer","format":"int64"},"by_state":{"type":"object","description":"Per-state asset counts keyed by the engine's state enum\n(`PEGGED` / `DRIFT` / `DEPEG` / `CRITICAL` / `BLACK_SWAN` / `UNKNOWN`).\nEmpty buckets are omitted, not zero-filled.","additionalProperties":{"type":"integer","format":"int64"},"propertyNames":{"type":"string"}},"delivery_health":{"$ref":"#/components/schemas/DeliveryHealth"}}},"SubView":{"type":"object","required":["id","asset","threshold_bps","channel","is_active","created_at"],"properties":{"asset":{"type":"string"},"channel":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"integer","format":"int64"},"is_active":{"type":"boolean"},"last_fired_at":{"type":["string","null"],"format":"date-time"},"threshold_bps":{"type":"integer","format":"int32"}}},"UserView":{"type":"object","required":["telegram_id","locale"],"properties":{"locale":{"type":"string"},"telegram_id":{"type":"integer","format":"int64"},"username":{"type":["string","null"]}}},"Webhook":{"type":"object","required":["id","url","asset","threshold_bps","is_active"],"properties":{"asset":{"type":"string"},"id":{"type":"string","format":"uuid"},"is_active":{"type":"boolean"},"threshold_bps":{"type":"integer","format":"int32"},"url":{"type":"string"}}},"WebhookIpsResponse":{"type":"object","required":["ips","recheck_after"],"properties":{"ips":{"type":"array","items":{"type":"string"},"description":"IPv4 / IPv6 addresses Pegana POSTs from when delivering webhooks.\nSubscribers running zero-trust ingress can allowlist these."},"recheck_after":{"type":"string","format":"date-time","description":"Hint to refresh this list weekly — IPs can change without notice\nduring infrastructure migrations."}}},"WebhookKeysResponse":{"type":"object","required":["pubkeys_b64","recheck_after"],"properties":{"pubkeys_b64":{"type":"array","items":{"type":"string"},"description":"Base64-encoded Ed25519 public keys currently signing outbound\nwebhooks. Index 0 is the **primary** (every new delivery is\nsigned with this); index 1 (when present) is the **secondary**,\nactive only during a rotation window. Receivers should trust\nANY of the listed keys when verifying `x-pegana-signature`."},"recheck_after":{"type":"string","format":"date-time","description":"Hint to refresh weekly — during a rotation this list flips\nfrom `[A]` → `[A, B]` → `[B]` over the rotation window. Polling\nat least once between deploys is enough to follow it."}}}},"securitySchemes":{"telegram_jwt":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"Session JWT issued by `POST /v1/auth/telegram` (Telegram Login Widget) or `POST /v1/auth/magic/consume`. Send as `Authorization: Bearer <jwt>`."}}},"tags":[{"name":"Health","description":"Liveness and readiness probes"},{"name":"Assets","description":"Public read access to asset state, history, and metadata"},{"name":"Alerts","description":"Global feed of state transitions"},{"name":"Stats","description":"Aggregate counters and delivery health"},{"name":"Auth","description":"Telegram Login Widget → JWT"},{"name":"Me","description":"Authenticated user profile and preferences"},{"name":"Subscriptions","description":"User alert subscriptions"},{"name":"Webhooks","description":"User-managed Ed25519-signed webhooks"},{"name":"WebSocket","description":"Live state stream"},{"name":"audit","description":"Public receipts for alerts — methodology version, inputs frozen, replay bundles. All endpoints public, no auth, cacheable. ADR-0006 four-state response on /v1/audit/:id (200/202/404/410); ADR-0014 bounds (90-day max, 100-row max, 50k-row cap)."},{"name":"methodology","description":"Public lifecycle status of the active methodology — version + status (active/deprecated/broken) + optional fix_url when broken. Consumed by web/app/api/methodology to power the home-page trust strip."}]}