{
  "openapi": "3.1.0",
  "info": {
    "title": "SaaSDaq API",
    "version": "1.0.0",
    "summary": "Fantasy stock market for side projects — machine-callable.",
    "description": "Reads need no credentials. Writes take `Authorization: Bearer sdq_...`, minted by a human at /me.\n\nALL MONEY IS AN INTEGER STRING IN BASE UNITS: 1 HYPE = 1000000 uH, 1 share = 1000000 uS. Never a float.\n\nThe `amount` field on an order CHANGES UNIT WITH `side`: buy -> uH (cash to spend), sell -> uS (shares to sell).\n\nProse version, written for agents: /llms.txt",
    "license": { "name": "Proprietary", "identifier": "LicenseRef-Proprietary" }
  },
  "servers": [{ "url": "https://saasdaq.net" }],
  "tags": [
    { "name": "account", "description": "Who you are and what you hold" },
    { "name": "trading", "description": "Orders and the daily faucet" },
    { "name": "listing", "description": "Putting a product on the board" },
    { "name": "public", "description": "Edge-cached reads, no credentials" }
  ],
  "security": [{ "bearerAuth": [] }],
  "paths": {
    "/api/v1/me": {
      "get": {
        "tags": ["account"],
        "operationId": "getMe",
        "summary": "Account, holdings and this key's scopes",
        "description": "One call on purpose: an agent needs cash and positions before it can decide anything, and requests carrying Authorization always bypass the edge cache. Check `scopes` before attempting a listing.",
        "responses": {
          "200": {
            "description": "Account snapshot",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Me" } }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/trade": {
      "post": {
        "tags": ["trading"],
        "operationId": "placeTrade",
        "summary": "Buy or sell",
        "description": "Requires the `trade` scope. Rate limit 10/min per key. Replaying an `idempotencyKey` returns the original receipt and writes nothing (`replayed: true`) — reuse it to retry a timeout, generate a new UUID to place a genuinely new order.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/TradeOrder" } }
          }
        },
        "responses": {
          "200": {
            "description": "Filled, or replayed from an earlier identical request",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/TradeReceipt" } }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "422": { "$ref": "#/components/responses/Unprocessable" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/api/v1/faucet": {
      "post": {
        "tags": ["trading"],
        "operationId": "claimFaucet",
        "summary": "Claim the daily allowance",
        "description": "Requires the `trade` scope — the faucet is fuel for trading, so it shares that scope. Once per UTC day; a second call returns FAUCET_TOO_SOON and writes nothing. Note that rank is netWorth/totalFaucetReceived, so claiming raises the denominator too: for an account that is up, it pulls ROI back toward break even. Sleep until `nextClaimAt` instead of polling.",
        "responses": {
          "200": {
            "description": "Claimed",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/FaucetReceipt" } }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/api/v1/listing": {
      "post": {
        "tags": ["listing"],
        "operationId": "listProduct",
        "summary": "List a product",
        "description": "Requires the `listing` scope. Costs a non-refundable 50 HYPE bond and the 100 founder shares are locked forever. The ticker is minted by the server. One domain can be listed only once, which makes the domain its own idempotency key: a replay returns DUPLICATE_DOMAIN without charging a second bond. Rate limit 3/min per key.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/ListingInput" } }
          }
        },
        "responses": {
          "201": {
            "description": "Listed — the ticker is new information",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ListingResult" } }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "409": { "$ref": "#/components/responses/Conflict" },
          "422": { "$ref": "#/components/responses/Unprocessable" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/api/index": {
      "get": {
        "tags": ["public"],
        "operationId": "getBoard",
        "summary": "The three boards, paginated",
        "description": "`index` is a literal path segment, not a directory default — `/api` alone is a 404. The first page also carries a `feed` array of recent trades, each with a `via` of `web` or `api`.",
        "security": [],
        "parameters": [
          {
            "name": "tab",
            "in": "query",
            "description": "cap = market cap, gainers = 24h change, wolves = trader ROI",
            "schema": { "type": "string", "enum": ["cap", "gainers", "wolves"], "default": "cap" }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Opaque cursor from the previous page",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "One page. `stale: true` means this is a ~5 minute old D1 snapshot served because the ledger was unreachable.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/api/p/{ticker}": {
      "get": {
        "tags": ["public"],
        "operationId": "getMarket",
        "summary": "One market in full",
        "security": [],
        "parameters": [{ "$ref": "#/components/parameters/Ticker" }],
        "responses": {
          "200": {
            "description": "Market detail. Cached 5s at the edge.",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/api/p/{ticker}/candles": {
      "get": {
        "tags": ["public"],
        "operationId": "getCandles",
        "summary": "Hourly candles",
        "description": "An empty array is a 200, not a 404: a market listed less than an hour ago genuinely has no candles yet.",
        "security": [],
        "parameters": [
          { "$ref": "#/components/parameters/Ticker" },
          {
            "name": "tf",
            "in": "query",
            "description": "Only `1h` exists today.",
            "schema": { "type": "string", "enum": ["1h"], "default": "1h" }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": { "type": "integer", "minimum": 1, "maximum": 168, "default": 168 }
          }
        ],
        "responses": {
          "200": {
            "description": "Candles, oldest first",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/api/u/{handle}": {
      "get": {
        "tags": ["public"],
        "operationId": "getTrader",
        "summary": "A trader's public profile",
        "security": [],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "description": "Case-insensitive; stored lowercase.",
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "Public profile",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/api/search": {
      "get": {
        "tags": ["public"],
        "operationId": "searchProducts",
        "summary": "Product search",
        "description": "The response echoes the normalised `query` so a client can discard responses for a query it has already moved past.",
        "security": [],
        "parameters": [
          { "name": "q", "in": "query", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "Hits, best match first",
            "content": { "application/json": { "schema": { "type": "object" } } }
          },
          "503": { "$ref": "#/components/responses/Unavailable" }
        }
      }
    },
    "/api/totals": {
      "get": {
        "tags": ["public"],
        "operationId": "getTotals",
        "summary": "Site totals, about 5 minutes old",
        "description": "A snapshot, not live: `capturedAt` says when. `totals: null` means no snapshot has been written yet.",
        "security": [],
        "responses": {
          "200": {
            "description": "Totals",
            "content": { "application/json": { "schema": { "type": "object" } } }
          }
        }
      }
    },
    "/api/out/{ticker}": {
      "get": {
        "tags": ["public"],
        "operationId": "visitProduct",
        "summary": "Redirect to the product, counting a click",
        "description": "302 to the product's real URL and increments its public click counter. Use it when a human asked to visit; use the URL from /api/p/{ticker} when you only need to fetch.",
        "security": [],
        "parameters": [{ "$ref": "#/components/parameters/Ticker" }],
        "responses": {
          "302": { "description": "Redirect to the product URL" },
          "404": { "$ref": "#/components/responses/NotFound" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "A key minted by a human at /me. Format: `sdq_` followed by 40 hex characters. Scopes are fixed at mint time — `trade` (orders and the faucet) and `listing` (new products). There is no endpoint that issues keys: a key that could mint another key would make revocation meaningless."
      }
    },
    "parameters": {
      "Ticker": {
        "name": "ticker",
        "in": "path",
        "required": true,
        "description": "3–6 uppercase letters or digits, no `$` prefix.",
        "schema": { "type": "string", "pattern": "^[A-Z0-9]{3,6}$" },
        "example": "LNR"
      }
    },
    "responses": {
      "BadRequest": {
        "description": "VALIDATION_FAILED, AMOUNT_TOO_SMALL, URL_NOT_ALLOWED",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unauthorized": {
        "description": "UNAUTHORIZED (no credentials) or INVALID_API_KEY (unknown or revoked)",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Forbidden": {
        "description": "INSUFFICIENT_SCOPE, SHADOW_BANNED, POSITION_LOCKED, ACCOUNT_TOO_NEW",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "NotFound": {
        "description": "MARKET_NOT_FOUND, USER_NOT_FOUND",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Conflict": {
        "description": "FAUCET_TOO_SOON, DUPLICATE_DOMAIN, MARKET_FROZEN, MARKET_DELISTED",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unprocessable": {
        "description": "INSUFFICIENT_BALANCE, INSUFFICIENT_SHARES, CAP_EXCEEDED — the request was well formed, the account cannot cover it",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "RateLimited": {
        "description": "RATE_LIMITED. Counted per key, per minute, before any other work.",
        "headers": {
          "retry-after": { "description": "Seconds to wait", "schema": { "type": "integer" } }
        },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      },
      "Unavailable": {
        "description": "TRADING_DISABLED, LISTING_DISABLED, PRE_MARKET, or the ledger is unreachable. Safe to retry with backoff.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
      }
    },
    "schemas": {
      "Micro": {
        "type": "string",
        "pattern": "^-?\\d{1,20}$",
        "description": "An integer amount in base units, as a STRING. 1 HYPE = 1000000 uH, 1 share = 1000000 uS. A string because JSON numbers lose precision past 2^53 — do not parse it as a float."
      },
      "Bps": {
        "type": "integer",
        "description": "Basis points. 10000 = break even / unchanged."
      },
      "Error": {
        "type": "object",
        "required": ["error", "message"],
        "properties": {
          "error": {
            "type": "string",
            "description": "Stable machine-readable code. Branch on this, never on `message`.",
            "example": "INSUFFICIENT_BALANCE"
          },
          "message": {
            "type": "string",
            "description": "Human copy. Subject to change without notice."
          },
          "detail": { "type": "string", "description": "Which field, or which limit." }
        }
      },
      "User": {
        "type": "object",
        "required": ["id", "handle", "isAdmin", "shadowBanned"],
        "properties": {
          "id": { "type": "string" },
          "handle": { "type": "string", "description": "Public, lowercase. Your /u/{handle} page." },
          "name": { "type": ["string", "null"] },
          "image": { "type": ["string", "null"] },
          "isAdmin": { "type": "boolean" },
          "shadowBanned": { "type": "boolean" }
        }
      },
      "Account": {
        "type": "object",
        "required": ["hype", "totalFaucetReceived", "pendingRoyalty", "streakDays"],
        "properties": {
          "hype": { "$ref": "#/components/schemas/Micro", "description": "Cash, uH" },
          "totalFaucetReceived": {
            "$ref": "#/components/schemas/Micro",
            "description": "Lifetime faucet + signup grant, uH. The ROI denominator."
          },
          "pendingRoyalty": {
            "$ref": "#/components/schemas/Micro",
            "description": "Founder fee share not yet settled, uH"
          },
          "streakDays": { "type": "integer" },
          "lastFaucetAt": { "type": ["integer", "null"], "description": "Unix ms" },
          "nextClaimAt": {
            "type": ["integer", "null"],
            "description": "Next UTC midnight, Unix ms. null = never claimed, claimable now."
          }
        }
      },
      "Position": {
        "type": "object",
        "required": ["ticker", "shares", "sellable", "locked", "costBasis", "value", "price", "pnlBps", "status"],
        "properties": {
          "ticker": { "type": "string" },
          "shares": { "$ref": "#/components/schemas/Micro", "description": "Held, uS, including locked" },
          "sellable": {
            "$ref": "#/components/schemas/Micro",
            "description": "shares - locked, uS. This is the ceiling for a sell order."
          },
          "locked": {
            "$ref": "#/components/schemas/Micro",
            "description": "Founder shares, uS. Locked forever — selling them returns POSITION_LOCKED."
          },
          "costBasis": { "$ref": "#/components/schemas/Micro", "description": "Net invested, uH" },
          "value": {
            "$ref": "#/components/schemas/Micro",
            "description": "Proceeds from closing the whole position now, uH, before the 1% fee"
          },
          "price": {
            "$ref": "#/components/schemas/Micro",
            "description": "uH per share, decayed to `queriedAt`. Not a live quote."
          },
          "pnlBps": { "$ref": "#/components/schemas/Bps" },
          "status": { "type": "string", "enum": ["active", "delisted"] }
        }
      },
      "Portfolio": {
        "type": "object",
        "required": ["cash", "pendingRoyalty", "faucetTotal", "netWorth", "roiBps", "positions", "queriedAt"],
        "properties": {
          "cash": { "$ref": "#/components/schemas/Micro" },
          "pendingRoyalty": { "$ref": "#/components/schemas/Micro" },
          "faucetTotal": { "$ref": "#/components/schemas/Micro" },
          "netWorth": {
            "$ref": "#/components/schemas/Micro",
            "description": "cash + pendingRoyalty + the liquidation value of every position, uH"
          },
          "roiBps": {
            "$ref": "#/components/schemas/Bps",
            "description": "netWorth / faucetTotal. This is what the Wolves board ranks."
          },
          "positions": {
            "type": "array",
            "items": { "$ref": "#/components/schemas/Position" }
          },
          "queriedAt": {
            "type": "integer",
            "description": "Unix ms. Every price above decays continuously (72h half-life), so this snapshot is only exact at this instant."
          }
        }
      },
      "Me": {
        "type": "object",
        "required": ["user", "account", "portfolio", "scopes"],
        "properties": {
          "user": { "$ref": "#/components/schemas/User" },
          "account": { "$ref": "#/components/schemas/Account" },
          "portfolio": { "$ref": "#/components/schemas/Portfolio" },
          "scopes": {
            "type": "array",
            "items": { "type": "string", "enum": ["trade", "listing"] },
            "description": "What this key may do. The only way to know without trying and reading a 403."
          }
        }
      },
      "TradeOrder": {
        "type": "object",
        "required": ["ticker", "side", "amount", "idempotencyKey"],
        "properties": {
          "ticker": { "type": "string", "pattern": "^[A-Z0-9]{3,6}$", "example": "LNR" },
          "side": { "type": "string", "enum": ["buy", "sell"] },
          "amount": {
            "type": "string",
            "pattern": "^\\d{1,18}$",
            "description": "UNIT DEPENDS ON `side`: buy -> uH to spend, sell -> uS to sell. \"5000000\" is 5 HYPE on a buy and 5 shares on a sell. Minimum trade is 1 HYPE.",
            "example": "5000000"
          },
          "idempotencyKey": {
            "type": "string",
            "format": "uuid",
            "description": "Generate one per intended order. Retrying with the same value returns the original receipt and writes nothing; buying the same amount again requires a new UUID."
          }
        }
      },
      "TradeReceipt": {
        "type": "object",
        "required": ["ticker", "side", "hypeDelta", "sharesDelta", "fee", "priceAfter", "balance", "positionShares", "createdAt", "replayed"],
        "properties": {
          "ticker": { "type": "string" },
          "side": { "type": "string", "enum": ["buy", "sell"] },
          "hypeDelta": {
            "$ref": "#/components/schemas/Micro",
            "description": "Cash flow from your side, uH: negative on a buy, positive on a sell"
          },
          "sharesDelta": {
            "$ref": "#/components/schemas/Micro",
            "description": "Share change, uS: positive on a buy, negative on a sell"
          },
          "fee": {
            "$ref": "#/components/schemas/Micro",
            "description": "1% — half burned, half paid to the product's founder"
          },
          "priceAfter": {
            "$ref": "#/components/schemas/Micro",
            "description": "Marginal price after the fill, uH per share. NOT your average fill price — divide hypeDelta by sharesDelta for that."
          },
          "balance": { "$ref": "#/components/schemas/Micro" },
          "positionShares": { "$ref": "#/components/schemas/Micro" },
          "createdAt": { "type": "integer" },
          "replayed": {
            "type": "boolean",
            "description": "true = this idempotencyKey was already used; nothing moved and these are the original numbers"
          },
          "marketAfter": {
            "type": ["object", "null"],
            "description": "Market state after the fill. null on a replay."
          }
        }
      },
      "FaucetReceipt": {
        "type": "object",
        "required": ["amount", "balance", "streakDays", "nextClaimAt", "totalFaucetReceived"],
        "properties": {
          "amount": {
            "$ref": "#/components/schemas/Micro",
            "description": "Claimed this time, uH. 200 HYPE plus 20 per streak day, capped at 400."
          },
          "balance": { "$ref": "#/components/schemas/Micro" },
          "streakDays": { "type": "integer" },
          "nextClaimAt": { "type": "integer", "description": "Next UTC midnight, Unix ms" },
          "totalFaucetReceived": {
            "$ref": "#/components/schemas/Micro",
            "description": "The ROI denominator — this claim raised it too"
          }
        }
      },
      "ListingInput": {
        "type": "object",
        "required": ["name", "url", "tagline", "category"],
        "properties": {
          "name": { "type": "string", "minLength": 1, "maxLength": 80 },
          "url": {
            "type": "string",
            "minLength": 3,
            "maxLength": 2048,
            "description": "A bare host is accepted — `linear.app` becomes `https://linear.app`. Tracking parameters are stripped.",
            "example": "linear.app"
          },
          "tagline": { "type": "string", "minLength": 1, "maxLength": 140 },
          "category": { "type": "string", "enum": ["AI", "DEV", "INFRA", "PROD"] }
        }
      },
      "ListingResult": {
        "type": "object",
        "required": ["productId", "ticker", "url", "domain", "receipt"],
        "properties": {
          "productId": { "type": "string" },
          "ticker": {
            "type": "string",
            "description": "Minted by the server — you cannot request one"
          },
          "url": { "type": "string", "description": "After normalisation" },
          "domain": {
            "type": "string",
            "description": "The uniqueness key. Listing it twice returns DUPLICATE_DOMAIN and charges nothing."
          },
          "receipt": {
            "type": "object",
            "required": ["ticker", "reserve", "shares", "lockedShares", "price", "founderBalance", "listedAt", "tR"],
            "properties": {
              "reserve": { "$ref": "#/components/schemas/Micro", "description": "R0 = the 50 HYPE bond, uH" },
              "shares": { "$ref": "#/components/schemas/Micro", "description": "S0, uS" },
              "lockedShares": {
                "$ref": "#/components/schemas/Micro",
                "description": "Founder shares, uS. Equals `shares` at listing and never unlocks."
              },
              "price": { "$ref": "#/components/schemas/Micro", "description": "P0 = 2R/S, uH per share" },
              "founderBalance": { "$ref": "#/components/schemas/Micro", "description": "Your cash after the bond, uH" },
              "listedAt": { "type": "integer" },
              "tR": {
                "type": "integer",
                "description": "Where the 72h decay clock starts. Equals listedAt, except for a pre-market listing where it points at the opening bell."
              }
            }
          }
        }
      }
    }
  }
}
