{
  "openapi": "3.1.0",
  "info": {
    "title": "patternfetch",
    "version": "0.3.0",
    "description": "Agent-first API + MCP: a stock, ETF, or crypto ticker + timeframe -> a token-compact market-state brief (compact candles, chart/candlestick patterns, support/resistance, regime, interpreted indicators, and a one-line summary). Covers US stocks & ETFs (Yahoo, split & dividend adjusted, delayed/EOD) and crypto (Binance spot, realtime) in one schema. Built so an LLM agent never dumps raw OHLCV into context. Free tier: keyless POST /v1/demo, free MCP tools/list, and a free key with $3.00 starter credit (300 briefs) via POST /v1/keys — no card. The credit is staged: at least $0.50 is usable immediately, and confirming your email address releases the remainder (where confirmation is not required, the full $3.00 lands at signup). Confirming never gates API access; it only releases the rest of the credit. Studio subscription: $19/month with 2,500 briefs ($25 of usage) included, then pay-per-call. Agents can also connect via OAuth (one-click, auto free-tier key) — see /.well-known/oauth-protected-resource. Impersonal market data, NOT investment advice.",
    "termsOfService": "https://patternfetch.com/terms",
    "contact": {
      "name": "patternfetch",
      "url": "https://patternfetch.com/docs",
      "email": "mail@patternfetch.com"
    }
  },
  "servers": [
    {
      "url": "https://patternfetch.com"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    },
    {
      "apiKeyAuth": []
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "API key from POST /v1/keys (or an OAuth-minted token). Header: Authorization: Bearer pf_..."
      },
      "apiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization",
        "description": "The SAME credential and the SAME header as bearerAuth (Authorization: Bearer pf_...), spelled as an apiKey-in-header scheme. Both are listed because x402/agentcash discovery crawlers only recognise an API key when the scheme's type is literally `apiKey` — with `type: http` alone, every protected operation was reported as `auth mode missing` and the whole API read as unmonetized. The two entries describe one header, so the OR between them is exact."
      }
    },
    "schemas": {
      "Query": {
        "type": "object",
        "required": [
          "ticker",
          "timeframe"
        ],
        "properties": {
          "ticker": {
            "type": "string",
            "example": "AAPL",
            "description": "Stock, ETF, or crypto ticker (e.g. AAPL, SPY, BTC/USDT). A bare symbol is always the US listing: BTC is a listed ETF, not Bitcoin - use BTC/USDT for the coin. Affected responses carry a notice field."
          },
          "timeframe": {
            "type": "string",
            "enum": [
              "1m",
              "5m",
              "15m",
              "30m",
              "1h",
              "4h",
              "1d",
              "1w"
            ]
          },
          "limit": {
            "type": "integer",
            "minimum": 20,
            "maximum": 1000,
            "description": "Candles to analyze (default 200) NOTE: values above the maximum are CLAMPED, not rejected — limit:5000 returns 200 with 1000 candles. Values below the minimum are a 400."
          },
          "market": {
            "type": "string",
            "enum": [
              "crypto",
              "stock"
            ],
            "description": "Asset-class hint. Omitted = inferred from the symbol, which is exactly where an ambiguous ticker goes wrong: bare \"BTC\" is a real US security, not Bitcoin. Send market:'crypto' with BTC/USDT, or market:'stock' to force the equity leg. Any other value is a 400."
          }
        }
      },
      "MultiQuery": {
        "type": "object",
        "required": [
          "ticker"
        ],
        "properties": {
          "ticker": {
            "type": "string",
            "example": "BTC/USDT",
            "description": "Stock, ETF, or crypto ticker (e.g. AAPL, SPY, BTC/USDT). A bare symbol is always the US listing: BTC is a listed ETF, not Bitcoin - use BTC/USDT for the coin. Affected responses carry a notice field."
          },
          "timeframes": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "1m",
                "5m",
                "15m",
                "30m",
                "1h",
                "4h",
                "1d",
                "1w"
              ]
            },
            "description": "Timeframes to compare (default [\"1h\",\"4h\",\"1d\"]); max 4, deduped, shortest to longest."
          },
          "limit": {
            "type": "integer",
            "minimum": 20,
            "maximum": 1000,
            "description": "Candles per timeframe (default 200)"
          },
          "market": {
            "type": "string",
            "enum": [
              "crypto",
              "stock"
            ],
            "description": "Asset-class hint. Omitted = inferred from the symbol, which is exactly where an ambiguous ticker goes wrong: bare \"BTC\" is a real US security, not Bitcoin. Send market:'crypto' with BTC/USDT, or market:'stock' to force the equity leg. Any other value is a 400."
          }
        }
      },
      "AnalogsQuery": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Query"
          },
          {
            "type": "object",
            "properties": {
              "window": {
                "type": "integer",
                "minimum": 2,
                "maximum": 500,
                "description": "Shape-match window length (default 32)"
              },
              "horizon": {
                "type": "integer",
                "minimum": 2,
                "maximum": 500,
                "description": "Forward horizon for the outcome distribution (default 20)"
              }
            }
          },
          {
            "type": "object",
            "properties": {
              "market": {
                "type": "string",
                "enum": [
                  "crypto",
                  "stock"
                ],
                "description": "Asset-class hint. Omitted = inferred from the symbol, which is exactly where an ambiguous ticker goes wrong: bare \"BTC\" is a real US security, not Bitcoin. Send market:'crypto' with BTC/USDT, or market:'stock' to force the equity leg. Any other value is a 400."
              }
            }
          }
        ]
      },
      "Brief": {
        "type": "object",
        "description": "Token-compact market-state brief. Returned by /v1/brief and /v1/demo.",
        "properties": {
          "header": {
            "type": "object",
            "properties": {
              "sym": {
                "type": "string",
                "example": "BTC/USDT"
              },
              "tf": {
                "type": "string",
                "example": "4h"
              },
              "src": {
                "type": "string",
                "example": "binance",
                "enum": [
                  "binance",
                  "vwap-composite",
                  "yahoo",
                  "twelvedata"
                ],
                "description": "The venue that ACTUALLY served this series — not the one routing would have guessed. A failover shows up here."
              },
              "n": {
                "type": "integer",
                "example": 200
              },
              "t0": {
                "type": "integer",
                "description": "first candle open time (epoch ms)"
              },
              "t1": {
                "type": "integer",
                "description": "last candle open time (epoch ms)"
              },
              "asOf": {
                "type": "integer",
                "description": "Epoch ms of the last bar in the series. What the brief is actually AS OF — not when it was requested."
              },
              "delay": {
                "type": "string",
                "enum": [
                  "realtime",
                  "delayed/EOD"
                ],
                "description": "Freshness class of the serving source. Equity sources are delayed/EOD; crypto is realtime."
              },
              "adjusted": {
                "type": "boolean",
                "description": "Present (true) only for equity sources, meaning the series is split/dividend adjusted. Omitted entirely for crypto."
              }
            }
          },
          "codec": {
            "type": "object",
            "description": "Compact candle codec.",
            "properties": {
              "rows": {
                "type": "string",
                "description": "Per-candle o,h,l,c,v joined by ';' (no per-row timestamp)."
              },
              "sax": {
                "type": "string",
                "description": "a-g SAX shape signature (<=8 chars)."
              },
              "precision": {
                "type": "integer",
                "description": "Decimal precision used in rows."
              }
            }
          },
          "analysis": {
            "type": "object",
            "properties": {
              "patterns": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "name": {
                      "type": "string",
                      "description": "Pattern name. Open set — new detectors add names, so do not treat this as a closed enum."
                    },
                    "confidence": {
                      "type": "number",
                      "description": "geometric quality score 0..1 (NOT a probability of profit)"
                    },
                    "at": {
                      "type": "integer"
                    },
                    "span": {
                      "type": "integer"
                    },
                    "evidence": {
                      "$ref": "#/components/schemas/PatternEvidence"
                    }
                  }
                }
              },
              "levels": {
                "type": "object",
                "properties": {
                  "support": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "price": {
                          "type": "number"
                        },
                        "strength": {
                          "type": "number"
                        }
                      }
                    }
                  },
                  "resistance": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "price": {
                          "type": "number"
                        },
                        "strength": {
                          "type": "number"
                        }
                      }
                    }
                  }
                }
              },
              "regime": {
                "type": "object",
                "properties": {
                  "trend": {
                    "type": "string",
                    "enum": [
                      "up",
                      "down",
                      "range"
                    ]
                  },
                  "strength": {
                    "type": "number"
                  },
                  "volPct": {
                    "type": "number",
                    "description": "ATR as % of price"
                  }
                }
              },
              "indicators": {
                "type": "object",
                "description": "Interpreted indicator states, keyed by indicator name. OPEN-ENDED: the code returns a map, so do not assume only rsi/ema are present.",
                "properties": {
                  "rsi": {
                    "type": "object",
                    "properties": {
                      "v": {
                        "type": "number"
                      },
                      "state": {
                        "type": "string",
                        "enum": [
                          "overbought",
                          "oversold",
                          "neutral"
                        ]
                      }
                    }
                  },
                  "ema": {
                    "type": "object",
                    "properties": {
                      "v": {
                        "type": "number"
                      },
                      "state": {
                        "type": "string"
                      }
                    }
                  }
                },
                "additionalProperties": true
              },
              "nl": {
                "type": "string",
                "description": "One-line natural-language summary the agent can act on."
              }
            }
          },
          "disclaimer": {
            "type": "string"
          },
          "methodologyUrl": {
            "type": "string",
            "example": "https://patternfetch.com/methodology"
          },
          "notice": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Caveats that change how the payload should be read — today the cross-listing warning (e.g. \"BTC\" is a US security here, not Bitcoin). Serialized as the FIRST key so it is seen before the data it qualifies. Absent when there is nothing to warn about."
          }
        }
      },
      "ScanRequest": {
        "type": "object",
        "description": "Optional filters for the screener (POST /v1/scan). Every field is optional; an absent field puts no constraint on that axis. A filter-less scan returns the top of the universe (limit defaults to 50).",
        "properties": {
          "assetClass": {
            "type": "string",
            "enum": [
              "stock",
              "crypto",
              "all"
            ],
            "description": "Restrict to US stocks/ETFs, crypto pairs, or all (default all)."
          },
          "regime": {
            "type": "string",
            "enum": [
              "up",
              "down",
              "range"
            ],
            "description": "Only tickers whose current regime trend matches (up=uptrend, down=downtrend, range=sideways)."
          },
          "tf": {
            "type": "string",
            "enum": [
              "1m",
              "5m",
              "15m",
              "30m",
              "1h",
              "4h",
              "1d",
              "1w"
            ],
            "description": "Only signals computed on this timeframe (the universe is currently precomputed at 1d)."
          },
          "pattern": {
            "type": "string",
            "description": "Require the ticker's top pattern to be this one, e.g. double_bottom, double_top, head_and_shoulders, bullish_engulfing, bearish_engulfing, hammer."
          },
          "minLift": {
            "type": "number",
            "minimum": -1,
            "maximum": 1,
            "description": "Drop tickers whose top pattern does not beat its OWN pattern-free baseline by at least this many rate points (0.02 = 2pp; 0 = at or above baseline; negative values allowed). PREFER THIS OVER minBaseRate: a raw base rate is not comparable across bullish and bearish rows, so minBaseRate:0.55 mostly selects bullish patterns in a rising universe before any of them carries information, whereas minLift selects patterns that measurably add something. Rows whose evidence carries no baseline are always excluded — absence of a lift is not a lift of 0."
          },
          "minBaseRate": {
            "type": "number",
            "minimum": 0,
            "maximum": 1,
            "description": "Drop tickers whose top-pattern backtested base rate is below this (0..1). Kept for existing consumers; see minLift for the drift-free equivalent, which is the better filter for an unbiased shortlist."
          },
          "limit": {
            "type": "integer",
            "minimum": 1,
            "maximum": 500,
            "description": "Max rows to return (default 50)."
          }
        }
      },
      "ScanResultRow": {
        "type": "object",
        "description": "One ranked discovery row from the screener.",
        "properties": {
          "sym": {
            "type": "string",
            "example": "AAPL",
            "description": "Ticker (stock/ETF symbol or crypto pair)."
          },
          "tf": {
            "type": "string",
            "example": "1d",
            "description": "Timeframe the signal was computed on."
          },
          "assetClass": {
            "type": "string",
            "enum": [
              "stock",
              "crypto"
            ]
          },
          "regime": {
            "type": "string",
            "enum": [
              "up",
              "down",
              "range"
            ],
            "description": "Current regime trend."
          },
          "pattern": {
            "type": [
              "string",
              "null"
            ],
            "description": "Top pattern name, or null if none."
          },
          "baseRate": {
            "type": [
              "number",
              "null"
            ],
            "description": "Backtested directional base rate 0..1 — the primary ranking key; null when no sufficiently-powered evidence bucket exists (ranks last)."
          },
          "ci95": {
            "type": [
              "number",
              "null"
            ],
            "description": "95% confidence-interval half-width on baseRate."
          },
          "n": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Independent historical occurrences behind the base rate."
          },
          "scope": {
            "type": [
              "string",
              "null"
            ],
            "description": "Asset-class corpus the base rate was measured over, e.g. 'US stocks & ETFs' or 'major crypto pairs'."
          },
          "confidence": {
            "type": [
              "number",
              "null"
            ],
            "description": "Geometric fit score 0..1 of the top pattern (secondary ranking key)."
          },
          "asOf": {
            "type": [
              "string",
              "null"
            ],
            "example": "2026-07-18",
            "description": "ISO date (UTC) of the last bar behind this signal. Freshness, and the final ranking tie-break."
          },
          "baseline": {
            "type": [
              "number",
              "null"
            ],
            "description": "Direction-matched pattern-free rate over the same timeframe and horizon: what the SAME bet pays with no pattern present. null on rows built from an evidence table without baselines."
          },
          "lift": {
            "type": [
              "number",
              "null"
            ],
            "description": "baseRate - baseline, in rate points. THE COMPARABLE NUMBER: baseRate alone is not comparable between bullish and bearish rows, because in a drifting universe a bullish pattern starts ahead and a bearish one starts behind with no information involved. Negative means the pattern underperforms its own baseline. null when no baseline exists."
          },
          "liftCi95": {
            "type": [
              "number",
              "null"
            ],
            "description": "95% confidence-interval half-width on lift. |lift| <= liftCi95 means the edge is not distinguishable from zero at this sample size."
          },
          "liftReading": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "above-baseline",
              "below-baseline",
              "indistinguishable-from-baseline",
              null
            ],
            "description": "Plain reading of lift against its interval, so a caller does not have to redo the statistics. Most rows in a healthy universe are indistinguishable-from-baseline; that is an honest result, not a missing value."
          }
        }
      },
      "ScanResponse": {
        "type": "object",
        "description": "Ranked screener results. Returned by POST /v1/scan. Rows are ordered by baseRate desc, then confidence desc, then timeframe, then symbol.",
        "properties": {
          "asOf": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO date the universe was last precomputed."
          },
          "tfs": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Timeframes present in the scanned universe."
          },
          "universe": {
            "type": "integer",
            "description": "Number of tickers scanned."
          },
          "count": {
            "type": "integer",
            "description": "Number of rows returned after filtering + limit."
          },
          "results": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ScanResultRow"
            }
          },
          "note": {
            "type": "string",
            "description": "Impersonal-data / not-a-prediction note."
          },
          "disclaimer": {
            "type": "string"
          }
        }
      },
      "Error": {
        "type": "object",
        "description": "Uniform error envelope. Every non-2xx JSON response from /v1 has this shape; `error` is a stable machine code and `message` is human text that may change.",
        "required": [
          "error",
          "message"
        ],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "INVALID_TICKER",
              "INVALID_TIMEFRAME",
              "INVALID_PARAM",
              "INSUFFICIENT_DATA",
              "UNAUTHORIZED",
              "PAYMENT_REQUIRED",
              "RATE_LIMITED",
              "PAYLOAD_TOO_LARGE",
              "UPSTREAM_ERROR",
              "NOT_FOUND",
              "GEO_BLOCKED",
              "NOT_CONFIGURED"
            ],
            "description": "Stable error code. Branch on THIS, never on the message or the HTTP status alone."
          },
          "message": {
            "type": "string",
            "description": "Human-readable explanation. Wording is not part of the contract."
          }
        }
      },
      "WatchSubscription": {
        "type": "object",
        "description": "A watchlist subscription. The signing secret is NOT part of this projection — it is returned exactly once, by the create call.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "ticker": {
            "type": "string",
            "example": "BTC/USDT"
          },
          "timeframe": {
            "type": "string",
            "example": "1h"
          },
          "market": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "crypto",
              "stock",
              null
            ],
            "description": "Asset-class hint, or null to infer from the symbol."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS callback target."
          },
          "intervalSec": {
            "type": "integer",
            "description": "Poll interval actually in force (>= the timeframe floor)."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "lastPollAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "lastDeliveryAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Last time a callback was actually SENT (a poll that finds no change sends nothing)."
          },
          "nextPollAt": {
            "type": "string",
            "format": "date-time"
          },
          "failCount": {
            "type": "integer",
            "description": "Consecutive delivery failures; drives exponential backoff."
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "paused",
              "disabled"
            ],
            "description": "'paused' recovers by itself (insufficient_credit resumes after a top-up); 'disabled' needs a new subscription."
          },
          "statusReason": {
            "type": [
              "string",
              "null"
            ],
            "example": "insufficient_credit"
          }
        }
      },
      "NewsletterState": {
        "type": "object",
        "description": "Consent record. The status is DERIVED from the three timestamps, never stored as a fourth field that could disagree with them.",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "none",
              "pending",
              "subscribed",
              "unsubscribed"
            ]
          },
          "optInAt": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Epoch ms of the opt-in (timestamp 1 of 3)."
          },
          "confirmedAt": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Epoch ms of the double-opt-in confirmation (timestamp 2 of 3)."
          },
          "unsubAt": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Epoch ms of the unsubscribe (timestamp 3 of 3)."
          }
        }
      },
      "LiftEvidence": {
        "type": "object",
        "description": "The pattern's measured edge over a pattern-free baseline. THIS is the number that says whether the pattern carries information, as opposed to merely riding a drifting market.",
        "properties": {
          "baseline": {
            "type": "number",
            "description": "Direction-matched unconditional rate: P(up) for a bullish pattern, P(down) for a bearish one."
          },
          "baselineN": {
            "type": "integer",
            "description": "Windows behind the baseline."
          },
          "lift": {
            "type": "number",
            "description": "hitRate - baseline, in rate points. Can be negative."
          },
          "ci95": {
            "type": "number",
            "description": "95% confidence-interval half-width on lift."
          },
          "ci95Clustered": {
            "type": "number",
            "description": "Half-width widened for overlapping windows. Present when the occurrences are not independent; it is the more honest of the two."
          },
          "informative": {
            "type": "boolean",
            "description": "Whether lift is distinguishable from zero given the interval. False is a normal, honest outcome — most patterns most of the time."
          },
          "reading": {
            "type": "string",
            "enum": [
              "above-baseline",
              "below-baseline",
              "indistinguishable-from-baseline"
            ],
            "description": "Plain-language verdict, so a caller does not have to redo the statistics."
          }
        }
      },
      "PatternEvidence": {
        "type": "object",
        "description": "Backtested evidence for a detected pattern. Absent when the pattern has no sufficiently-powered evidence bucket — an omitted `evidence` means \"we have not measured this\", never \"it does not work\".",
        "properties": {
          "hitRate": {
            "type": "number",
            "description": "Directional hit rate over the horizon, 0..1."
          },
          "horizon": {
            "type": "integer",
            "description": "Bars forward the outcome was measured over."
          },
          "n": {
            "type": "integer",
            "description": "Independent historical occurrences."
          },
          "scope": {
            "type": "string",
            "example": "US stocks & ETFs",
            "description": "Corpus the rate was measured over."
          },
          "ci95": {
            "type": "number",
            "description": "95% confidence-interval half-width on hitRate."
          },
          "lift": {
            "$ref": "#/components/schemas/LiftEvidence"
          }
        }
      },
      "OutcomeDistribution": {
        "type": "object",
        "description": "FULL forward-return distribution, losers included. Never a single headline number.",
        "properties": {
          "n": {
            "type": "integer",
            "description": "Matches contributing a forward return."
          },
          "horizon": {
            "type": "integer",
            "description": "Bars forward."
          },
          "winRate": {
            "type": "number"
          },
          "median": {
            "type": "number"
          },
          "mean": {
            "type": "number"
          },
          "min": {
            "type": "number"
          },
          "max": {
            "type": "number"
          }
        }
      },
      "AnalogsResponse": {
        "type": "object",
        "description": "In-series analogs: the latest window matched against earlier windows of the SAME series.",
        "properties": {
          "notice": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Cross-listing caveat when the symbol is ambiguous. Derived here from the ROUTING decision (which is the decision that can be wrong), not from a serving source — this endpoint reports no src."
          },
          "sym": {
            "type": "string"
          },
          "tf": {
            "type": "string"
          },
          "window": {
            "type": "integer",
            "description": "Window length actually used."
          },
          "distribution": {
            "$ref": "#/components/schemas/OutcomeDistribution"
          },
          "matches": {
            "type": "integer",
            "description": "Analog windows found after de-duplication."
          },
          "note": {
            "type": "string"
          },
          "disclaimer": {
            "type": "string"
          }
        }
      },
      "MultiResponse": {
        "type": "object",
        "description": "One brief per timeframe plus a cross-timeframe alignment read.",
        "properties": {
          "sym": {
            "type": "string"
          },
          "views": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "tf": {
                  "type": "string"
                },
                "trend": {
                  "type": "string",
                  "enum": [
                    "up",
                    "down",
                    "range"
                  ]
                },
                "strength": {
                  "type": "number"
                },
                "nl": {
                  "type": "string"
                }
              }
            }
          },
          "alignment": {
            "type": "object",
            "properties": {
              "trends": {
                "type": "object",
                "additionalProperties": {
                  "type": "string",
                  "enum": [
                    "up",
                    "down",
                    "range"
                  ]
                },
                "description": "Trend per timeframe."
              },
              "aligned": {
                "type": "boolean"
              },
              "direction": {
                "type": "string",
                "enum": [
                  "up",
                  "down",
                  "range"
                ]
              },
              "divergence": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Which timeframes disagree, or null when they agree."
              }
            }
          },
          "nl": {
            "type": "string"
          },
          "briefs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Brief"
            }
          },
          "disclaimer": {
            "type": "string"
          }
        }
      },
      "DeltaResponse": {
        "type": "object",
        "description": "What changed since this account last looked at this ticker+timeframe.",
        "properties": {
          "delta": {
            "type": "object",
            "description": "NOTE: `changed` is here, not at the top level.",
            "properties": {
              "changed": {
                "type": "boolean"
              },
              "sym": {
                "type": "string"
              },
              "tf": {
                "type": "string"
              },
              "notes": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "What changed. On the first ever call for this pair it is ['initial brief'] with changed:true."
              }
            }
          },
          "brief": {
            "$ref": "#/components/schemas/Brief"
          }
        }
      },
      "CandlesResponse": {
        "type": "object",
        "properties": {
          "notice": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "header": {
            "type": "object",
            "description": "Deliberately SMALLER than Brief.header — only these four fields.",
            "properties": {
              "sym": {
                "type": "string"
              },
              "tf": {
                "type": "string"
              },
              "src": {
                "type": "string",
                "enum": [
                  "binance",
                  "vwap-composite",
                  "yahoo",
                  "twelvedata"
                ]
              },
              "n": {
                "type": "integer"
              }
            }
          },
          "codec": {
            "type": "object",
            "properties": {
              "rows": {},
              "sax": {},
              "precision": {}
            },
            "description": "Token-compact candle encoding."
          },
          "disclaimer": {
            "type": "string"
          }
        }
      },
      "KeysResponse": {
        "type": "object",
        "description": "The response shape is IDENTICAL for a first-time address and one that already has an account — no field, message or status distinguishes them, so this endpoint cannot be used to test whether someone has an account. `next` is therefore deliberately static: no balance, no grant amount.",
        "properties": {
          "key": {
            "type": "string",
            "description": "The API key. Shown once, here."
          },
          "next": {
            "type": "object",
            "properties": {
              "step": {
                "type": "string"
              },
              "example": {
                "type": "string",
                "description": "A runnable curl with the real key already substituted."
              },
              "docs": {
                "type": "string"
              },
              "pricing": {
                "type": "string"
              },
              "mcp": {
                "type": "string"
              }
            }
          },
          "disclaimer": {
            "type": "string"
          }
        }
      }
    }
  },
  "paths": {
    "/v1/brief": {
      "post": {
        "operationId": "getBrief",
        "x-payment-info": {
          "price": {
            "mode": "fixed",
            "currency": "USD",
            "amount": "0.01"
          },
          "protocols": [
            {
              "x402": {}
            }
          ]
        },
        "summary": "Token-compact market-state brief for a stock, ETF, or crypto ticker + timeframe ($0.01)",
        "description": "The full brief: compact candles, patterns, support/resistance, regime, interpreted indicators, and a one-line nl summary. Use this instead of dumping raw OHLCV into an LLM.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Query"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Brief",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Brief"
                }
              }
            }
          },
          "400": {
            "description": "Invalid ticker, timeframe or parameter (INVALID_TICKER / INVALID_TIMEFRAME / INVALID_PARAM).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Payment required (top up via x402 or Stripe)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "INSUFFICIENT_DATA — fewer than 20 usable candles came back, so no honest brief can be assembled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "451": {
            "description": "Geo-blocked: crypto data is not offered to US persons. Equity tickers are unaffected. Evaluated from the request body BEFORE authentication, so it can precede a 401.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Upstream data source failed. Genuinely transient — retrying is appropriate. An unknown SYMBOL is a 404, not this.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/multi": {
      "post": {
        "operationId": "getMulti",
        "x-payment-info": {
          "price": {
            "mode": "fixed",
            "currency": "USD",
            "amount": "0.025"
          },
          "protocols": [
            {
              "x402": {}
            }
          ]
        },
        "summary": "Multi-timeframe market-state view + cross-timeframe alignment ($0.025)",
        "description": "One call returns a token-compact brief per requested timeframe (default 1h, 4h, 1d) plus a cross-timeframe alignment read — whether the trends agree or diverge across horizons. Impersonal, descriptive; the divergence is data, not a directive.\n\nTimeframes are returned in the order you send them (deduplicated); they are NOT sorted shortest-to-longest.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MultiQuery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Per-timeframe briefs plus the alignment read.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MultiResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid ticker, timeframe or parameter (INVALID_TICKER / INVALID_TIMEFRAME / INVALID_PARAM).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Payment required (top up via x402 or Stripe)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "INSUFFICIENT_DATA — fewer than 20 usable candles came back, so no honest brief can be assembled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "451": {
            "description": "Geo-blocked: crypto data is not offered to US persons. Equity tickers are unaffected. Evaluated from the request body BEFORE authentication, so it can precede a 401.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Upstream data source failed. Genuinely transient — retrying is appropriate. An unknown SYMBOL is a 404, not this.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/delta": {
      "post": {
        "operationId": "getDelta",
        "x-payment-info": {
          "price": {
            "mode": "dynamic",
            "currency": "USD",
            "min": "0.001",
            "max": "0.008"
          },
          "protocols": [
            {
              "x402": {}
            }
          ]
        },
        "summary": "Only what changed since your last brief ($0.008, $0.001 when nothing changed)",
        "description": "Token-minimal for polling agents. Returns changed:false (cheaper) when nothing material moved; includes the full brief when it did.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Query"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The change since the last call. When nothing changed, `brief` is omitted and the call is billed at the cheaper empty-delta rate.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeltaResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid ticker, timeframe or parameter (INVALID_TICKER / INVALID_TIMEFRAME / INVALID_PARAM).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "402": {
            "description": "Payment required"
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "INSUFFICIENT_DATA — fewer than 20 usable candles came back, so no honest brief can be assembled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "451": {
            "description": "Geo-blocked: crypto data is not offered to US persons. Equity tickers are unaffected. Evaluated from the request body BEFORE authentication, so it can precede a 401.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Upstream data source failed. Genuinely transient — retrying is appropriate. An unknown SYMBOL is a 404, not this.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/candles": {
      "post": {
        "operationId": "getCandles",
        "x-payment-info": {
          "price": {
            "mode": "fixed",
            "currency": "USD",
            "amount": "0.005"
          },
          "protocols": [
            {
              "x402": {}
            }
          ]
        },
        "summary": "Compact candle codec only — rows + SAX shape signature ($0.005)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Query"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Token-compact candles.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CandlesResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid ticker, timeframe or parameter (INVALID_TICKER / INVALID_TIMEFRAME / INVALID_PARAM).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "402": {
            "description": "Payment required"
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "INSUFFICIENT_DATA — fewer than 20 usable candles came back, so no honest brief can be assembled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "451": {
            "description": "Geo-blocked: crypto data is not offered to US persons. Equity tickers are unaffected. Evaluated from the request body BEFORE authentication, so it can precede a 401.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Upstream data source failed. Genuinely transient — retrying is appropriate. An unknown SYMBOL is a 404, not this.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/analogs": {
      "post": {
        "operationId": "getAnalogs",
        "x-payment-info": {
          "price": {
            "mode": "fixed",
            "currency": "USD",
            "amount": "0.05"
          },
          "protocols": [
            {
              "x402": {}
            }
          ]
        },
        "summary": "Historical analogs as a full outcome distribution — not a prediction ($0.05)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AnalogsQuery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Analog outcome distribution. NOTE the nesting: n/horizon/winRate/median/mean/min/max live under `distribution`, not at the top level.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AnalogsResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid ticker, timeframe or parameter (INVALID_TICKER / INVALID_TIMEFRAME / INVALID_PARAM).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "402": {
            "description": "Payment required"
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "451": {
            "description": "Geo-blocked: crypto data is not offered to US persons. Equity tickers are unaffected. Evaluated from the request body BEFORE authentication, so it can precede a 401.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Upstream data source failed. Genuinely transient — retrying is appropriate. An unknown SYMBOL is a 404, not this.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/scan": {
      "post": {
        "operationId": "scan",
        "x-payment-info": {
          "price": {
            "mode": "fixed",
            "currency": "USD",
            "amount": "0.02"
          },
          "protocols": [
            {
              "x402": {}
            }
          ]
        },
        "summary": "Screener — scan US stocks, ETFs & crypto for tickers in a regime or printing a pattern, RANKED by honest backtested base rate ($0.02)",
        "description": "Discovery, not lookup: instead of asking about one ticker you already know, ask which tickers across the universe are currently in a given regime or printing a chart/candlestick pattern — and get them RANKED by the honest backtested base rate + 95% CI, so your model finds the few real signals instead of being handed one ticker at a time (a coin-flip pattern is flagged as a coin flip). Precomputed over a curated universe (liquid US large-caps + core/sector ETFs + major crypto pairs) so it is fast and cheap. All filters optional; results are ordered by base rate, then confidence, then timeframe, then symbol.",
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScanRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ranked screener results",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid filter value",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Payment required (top up via x402 or Stripe)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "451": {
            "description": "Geo-blocked: crypto data is not offered to US persons. Equity tickers are unaffected. Evaluated from the request body BEFORE authentication, so it can precede a 401.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Upstream data source failed. Genuinely transient — retrying is appropriate. An unknown SYMBOL is a 404, not this.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/demo": {
      "post": {
        "operationId": "getDemo",
        "summary": "Free no-signup demo brief (rate-limited, never billed)",
        "description": "Same body and output as /v1/brief, but needs no key. IP rate-limited. Great for trying it out.\n\nNOTE: `limit` is validated but NOT honoured here — the demo always returns 120 candles. Send it only if you want it range-checked.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Query"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Brief",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Brief"
                }
              }
            }
          },
          "400": {
            "description": "Invalid ticker, timeframe or parameter (INVALID_TICKER / INVALID_TIMEFRAME / INVALID_PARAM).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "INSUFFICIENT_DATA — fewer than 20 usable candles came back, so no honest brief can be assembled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demo rate limit reached — get a free key at /v1/keys"
          },
          "451": {
            "description": "Geo-blocked: crypto data is not offered to US persons. Equity tickers are unaffected. Evaluated from the request body BEFORE authentication, so it can precede a 401.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Upstream data source failed. Genuinely transient — retrying is appropriate. An unknown SYMBOL is a 404, not this.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/keys": {
      "post": {
        "operationId": "createKey",
        "summary": "Self-serve free API key with $3.00 starter credit (300 briefs)",
        "description": "Issues a working key instantly, no card. The starter credit is staged: at least $0.50 is usable right away with no confirmation step, and confirming the email address releases the remainder. Where email confirmation is not required, the full $3.00 is granted at signup. Confirming is never a precondition for calling the API — it only releases the rest of the credit.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "A new API key plus a runnable next step.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/KeysResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid email — malformed, or a known disposable/throwaway provider (those are refused; use a permanent address)."
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Too many key requests from this IP (burst 10, then ~6/hour). Minting is free, so it is throttled per IP.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/recover": {
      "post": {
        "operationId": "recoverKey",
        "summary": "Mail a single-use link that issues a replacement API key",
        "description": "For a lost key. The original cannot be re-sent — only a peppered hash of a key is ever stored — so this mails a single-use link to the address on the account, and opening that link issues a NEW key and shows it once. Opening the link DEACTIVATES every previous key on the account; the credit balance is untouched. Nothing changes until the link is opened, so an unrequested mail can safely be ignored. The response is byte-identical whether or not the address has an account, so this endpoint cannot be used to probe for registered addresses. Rate-limited per IP. Returns 503 NOT_CONFIGURED on a deployment with no mail provider configured, rather than returning a key to an unverified caller — the condition is permanent until an operator sets one, so it must not be retried.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "{ ok, message } — identical whether or not the address is known"
          },
          "400": {
            "description": "Invalid email"
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Too many recovery requests from this IP"
          },
          "503": {
            "description": "NOT_CONFIGURED — email delivery is not configured on this deployment, so recovery is unavailable. Not transient: retrying will not help until an operator configures a mail provider."
          }
        }
      },
      "get": {
        "operationId": "redeemKeyRecovery",
        "summary": "Redeem a recovery link and receive the replacement key (HTML)",
        "description": "The link mailed by POST /v1/recover. Single-use and time-limited; renders the new API key once, and states that previous keys have been deactivated. Reloading will not show the key again.",
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Single-use token from the recovery mail"
          }
        ],
        "responses": {
          "200": {
            "description": "HTML page showing the new key (shown once)",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "Link invalid, already used, or expired",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/v1/topup": {
      "get": {
        "operationId": "topupOffer",
        "x-payment-info": {
          "price": {
            "mode": "fixed",
            "currency": "USD",
            "amount": "1.00"
          },
          "protocols": [
            {
              "x402": {}
            }
          ]
        },
        "summary": "Read the x402 payment requirements for a credit top-up",
        "description": "Returns the same 402 payment challenge POST returns without an X-PAYMENT header. Safe and free of side effects: it never reads X-PAYMENT, never settles and never credits an account, so a directory prober or an agent can read the price without paying. Pay by POSTing to this same path.",
        "security": [],
        "responses": {
          "402": {
            "description": "Payment challenge (x402 payment requirements)"
          }
        }
      },
      "post": {
        "operationId": "topup",
        "x-payment-info": {
          "price": {
            "mode": "fixed",
            "currency": "USD",
            "amount": "1.00"
          },
          "protocols": [
            {
              "x402": {}
            }
          ]
        },
        "summary": "Top up credit with x402 (USDC on Base, no account)",
        "description": "Returns a 402 challenge (discovery) when no X-PAYMENT header is present; verifies + credits on a valid payment. The same challenge is readable with GET on this path, and at /.well-known/x402.json.",
        "responses": {
          "200": {
            "description": "Credited",
            "headers": {
              "X-PAYMENT-RESPONSE": {
                "schema": {
                  "type": "string"
                },
                "description": "x402 v1 settlement outcome, present on success AND failure so a rejected agent can read a typed reason instead of retrying blind."
              }
            }
          },
          "402": {
            "description": "Payment challenge",
            "headers": {
              "X-PAYMENT-RESPONSE": {
                "schema": {
                  "type": "string"
                },
                "description": "x402 v1 settlement outcome, present on success AND failure so a rejected agent can read a typed reason instead of retrying blind."
              }
            }
          },
          "500": {
            "description": "CREDIT_PENDING — the USDC SETTLED but recording the credit failed. DO NOT PAY AGAIN. The settlement is recorded on our side with its tx hash and will be applied by hand. This is deliberately NOT a 402: answering with a fresh price quote for something already paid for would invite a second transfer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            },
            "headers": {
              "X-PAYMENT-RESPONSE": {
                "schema": {
                  "type": "string"
                },
                "description": "x402 v1 settlement outcome, present on success AND failure so a rejected agent can read a typed reason instead of retrying blind."
              }
            }
          }
        }
      }
    },
    "/v1/checkout": {
      "post": {
        "operationId": "checkout",
        "summary": "Create a Stripe hosted-checkout URL to top up credit",
        "responses": {
          "200": {
            "description": "{ url }"
          },
          "401": {
            "description": "Unauthorized. NOTE: these two routes answer with `{\"error\":\"UNAUTHORIZED\"}` only — no `message` field, unlike every other endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "enum": [
                        "UNAUTHORIZED"
                      ]
                    }
                  }
                }
              }
            }
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "The payment provider call failed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/subscribe": {
      "post": {
        "operationId": "subscribe",
        "summary": "Create a Stripe checkout URL for the Studio subscription ($19/month, 2,500 briefs included)",
        "description": "Studio includes $25 of usage (2,500 briefs at $0.01) per month. The included allowance is spent before any prepaid credit; usage beyond it bills per call from the credit balance. Requires a bearer API key. Returns 404 when subscriptions are not configured on this deployment.",
        "responses": {
          "200": {
            "description": "{ url, manage } — the checkout link, plus a pointer to POST /v1/portal for later cancellation, card changes and invoices."
          },
          "401": {
            "description": "Unauthorized. NOTE: these two routes answer with `{\"error\":\"UNAUTHORIZED\"}` only — no `message` field, unlike every other endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "enum": [
                        "UNAUTHORIZED"
                      ]
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Subscriptions not enabled on this deployment"
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "The payment provider call failed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/portal": {
      "post": {
        "operationId": "billingPortal",
        "summary": "Create a Stripe billing-portal URL to manage your own billing",
        "description": "Returns a single-use, time-limited Stripe-hosted URL where the account holder can cancel the Studio subscription (effective at the end of the paid period, so the included allowance stays spendable until then), replace an expired or failing card, update the billing email/address/VAT id, and download past invoices. Requires a bearer API key; the portal opens the Stripe customer linked to that account. Returns 404 when the account has no billing history yet — the customer is created by the first top-up or subscription.",
        "responses": {
          "200": {
            "description": "{ url } — a Stripe-hosted billing-portal link. Single-use and short-lived: request a fresh one each time rather than storing it.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "url": {
                      "type": "string",
                      "format": "uri"
                    }
                  },
                  "required": [
                    "url"
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized. NOTE: these two routes answer with `{\"error\":\"UNAUTHORIZED\"}` only — no `message` field, unlike every other endpoint.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": {
                      "type": "string",
                      "enum": [
                        "UNAUTHORIZED"
                      ]
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "This account has no Stripe customer yet (it has never paid), so there is nothing to manage.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "500": {
            "description": "The payment provider call failed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/platforms": {
      "get": {
        "operationId": "getPlatforms",
        "summary": "Capabilities matrix (assets, timeframes, endpoints)",
        "security": [],
        "responses": {
          "200": {
            "description": "Capabilities"
          }
        }
      }
    },
    "/v1/status": {
      "get": {
        "operationId": "getStatus",
        "summary": "Service status (product, version, sources, assets)",
        "security": [],
        "responses": {
          "200": {
            "description": "Status { ok, product, version, assets[], sources{} }"
          }
        }
      }
    },
    "/healthz": {
      "get": {
        "operationId": "getHealth",
        "summary": "Liveness check",
        "security": [],
        "responses": {
          "200": {
            "description": "OK"
          }
        }
      }
    },
    "/mcp": {
      "post": {
        "operationId": "mcp",
        "summary": "MCP (Model Context Protocol) over Streamable HTTP",
        "description": "Tools: patternfetch_brief, patternfetch_multi, patternfetch_delta, patternfetch_analogs, patternfetch_scan, patternfetch_capabilities. initialize + tools/list are free (no key); tools/call needs a key (Bearer or OAuth) and is billed like the matching REST endpoint.",
        "security": [],
        "responses": {
          "200": {
            "description": "JSON-RPC / SSE response"
          },
          "400": {
            "description": "Body was not parseable JSON. Note this is the plain error envelope, NOT a JSON-RPC error object — the request never reached the JSON-RPC layer.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized (carries WWW-Authenticate for OAuth discovery)"
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/watch": {
      "get": {
        "operationId": "listWatches",
        "summary": "List watchlist subscriptions",
        "description": "Every subscription on the calling account. Free — managing subscriptions is not metered; only the polls are.\n\nRouted ONLY when the deployment has a subscription store wired; otherwise these paths 404 like any unknown route.",
        "tags": [
          "watchlist"
        ],
        "responses": {
          "200": {
            "description": "The account's subscriptions.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "subscriptions": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WatchSubscription"
                      }
                    },
                    "limit": {
                      "type": "integer",
                      "description": "Maximum subscriptions per account (20)."
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "405": {
            "description": "Method not allowed. The response carries an Allow header listing GET, POST, DELETE.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited — subscription management shares the data-call budget, so it cannot be used as an unthrottled side door.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "The subscription store is unavailable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "createWatch",
        "summary": "Watch a ticker and get an HTTPS callback when its state changes",
        "description": "Registers (ticker, timeframe) and polls it on a timer, pushing a callback only when the market STATE actually changes — the same diff POST /v1/delta returns, run for you and pushed instead of polled.\n\nBILLING: create, list and delete are free. Each POLL is metered at the price of the equivalent manual call (/v1/delta when something changed, /v1/delta-empty when not). If the account cannot pay, the subscription is PAUSED rather than deleted, and resumes by itself on the next tick after a top-up.\n\nThe signing `secret` is returned ONLY here, ONLY once. It is derived, never stored, and cannot be retrieved later.",
        "tags": [
          "watchlist"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ticker",
                  "timeframe",
                  "url"
                ],
                "properties": {
                  "ticker": {
                    "type": "string",
                    "example": "BTC/USDT"
                  },
                  "timeframe": {
                    "type": "string",
                    "enum": [
                      "1m",
                      "5m",
                      "15m",
                      "30m",
                      "1h",
                      "4h",
                      "1d",
                      "1w"
                    ]
                  },
                  "url": {
                    "type": "string",
                    "format": "uri",
                    "maxLength": 2048,
                    "description": "HTTPS callback. http is refused (the payload and its signature would travel in clear text), as are embedded credentials and any host resolving to a private, loopback, link-local or cloud-metadata address. That check is re-run immediately before EVERY delivery, so DNS rebinding does not defeat it."
                  },
                  "market": {
                    "type": "string",
                    "enum": [
                      "crypto",
                      "stock"
                    ],
                    "description": "Optional asset-class hint; omitted = inferred from the symbol."
                  },
                  "intervalSec": {
                    "type": "integer",
                    "description": "Poll interval in seconds. Floor is max(300, a quarter bar of the timeframe): a shorter interval cannot surface new information, because the brief is assembled from the same bars. Maximum 2592000 (30 days). Omitted = the floor."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Subscription created. Contains the only copy of the signing secret.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "subscription": {
                      "$ref": "#/components/schemas/WatchSubscription"
                    },
                    "secret": {
                      "type": "string",
                      "description": "HMAC signing secret. Shown once, here. Store it now — it cannot be read back."
                    },
                    "signature": {
                      "type": "object",
                      "description": "How to verify a callback.",
                      "properties": {
                        "header": {
                          "type": "string",
                          "example": "x-patternfetch-signature"
                        },
                        "timestampHeader": {
                          "type": "string",
                          "example": "x-patternfetch-timestamp"
                        },
                        "algorithm": {
                          "type": "string",
                          "example": "HMAC-SHA256 over `${timestamp}.${rawBody}`, hex, prefixed \"sha256=\""
                        },
                        "note": {
                          "type": "string",
                          "description": "Verify with a constant-time comparison and reject old timestamps."
                        }
                      }
                    },
                    "note": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid ticker, timeframe, interval, market or URL — or the 20-subscription-per-account cap is reached.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "Already watching this ticker+timeframe. The request is well-formed but conflicts with existing state — delete that subscription to change its target.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "The subscription store is unavailable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/watch/{id}": {
      "delete": {
        "operationId": "deleteWatch",
        "summary": "Delete a watchlist subscription",
        "description": "Free. A syntactically invalid id is a plain 404: the id is shape-checked before any query, so a malformed value never reaches the database.",
        "tags": [
          "watchlist"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Subscription id from create or list."
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "deleted": {
                      "type": "boolean"
                    },
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such subscription. Also returned for a malformed id, and for a subscription belonging to another account — the two are deliberately indistinguishable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "The subscription store is unavailable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/newsletter": {
      "get": {
        "operationId": "getNewsletterState",
        "summary": "Read the changelog subscription state for this account",
        "description": "Routed only when the deployment has a newsletter sink wired; otherwise 404.",
        "tags": [
          "account"
        ],
        "responses": {
          "200": {
            "description": "The current consent record.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "newsletter": {
                      "$ref": "#/components/schemas/NewsletterState"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "setNewsletterSubscription",
        "summary": "Subscribe to or unsubscribe from the changelog",
        "description": "Presenting a valid API key IS the confirmation here: the request is authenticated as the account owner, which is stronger evidence of consent than clicking a link in an email. So `subscribe: true` subscribes directly rather than sending a second double-opt-in mail.\n\n(The unauthenticated mail-link flow is separate: /v1/newsletter/confirm and /v1/newsletter/unsubscribe, where an HMAC token in the query string is the authentication. Those return HTML pages for a browser, not JSON, and are not part of this API surface.)",
        "tags": [
          "account"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "subscribe"
                ],
                "properties": {
                  "subscribe": {
                    "type": "boolean",
                    "description": "true subscribes (opt-in and confirm in one step), false unsubscribes. Must be a real boolean — anything else is a 400, so a missing or malformed field can never be read as consent."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The current consent record.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "newsletter": {
                      "$ref": "#/components/schemas/NewsletterState"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`subscribe` was absent or not a boolean.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/demo-multi": {
      "post": {
        "operationId": "demoMulti",
        "summary": "Multi-timeframe demo — no key required",
        "description": "The cross-timeframe view without a signup, so a link from Reddit/HN is runnable immediately. Never billed. Shares the SAME per-IP demo budget as POST /v1/demo.",
        "tags": [
          "demo"
        ],
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MultiQuery"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Same shape as /v1/multi.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MultiResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid ticker, timeframe or limit.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "Request body exceeds the 64KB cap. Shrink the request — retrying it unchanged cannot succeed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "INSUFFICIENT_DATA — fewer than 20 usable candles came back, so no honest brief can be assembled.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Demo rate limit reached — get a free key at POST /v1/keys.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "451": {
            "description": "Geo-blocked: crypto data is not offered to US persons. Equity tickers are unaffected. Evaluated from the request body BEFORE authentication, so it can precede a 401.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "Upstream data source failed. Genuinely transient — retrying is appropriate. An unknown SYMBOL is a 404, not this.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  }
}
